1 Commits

Author SHA1 Message Date
Renovate Bot
152555714f Update dependency @vitest/eslint-plugin to v1.6.10
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped
2026-03-09 17:03:24 +00:00
144 changed files with 1217 additions and 9440 deletions

View File

@@ -84,12 +84,31 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic
* (derzeit keine offenen Architekturentscheidungen) * (derzeit keine offenen Architekturentscheidungen)
## Nicht umgesetzte Feature-Ideen (ehemals Specs 009026) ## Nicht umgesetzte Feature-Ideen (ehemals Specs 009026)
### 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 ### 010 Event bearbeiten
Organisator kann Titel, Beschreibung, Datum, Ort und Ablaufdatum ändern. Organisator kann Titel, Beschreibung, Datum, Ort und Ablaufdatum ändern.
* Formular vorausgefüllt mit aktuellen Werten * Formular vorausgefüllt mit aktuellen Werten
* Ablaufdatum muss in der Zukunft liegen * Ablaufdatum muss in der Zukunft liegen
* Ohne Organizer-Token kein Edit-UI sichtbar * 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 ### 013 Kalender-Export
.ics-Download (RFC 5545) mit Event-Details, optional webcal:// für Live-Updates. .ics-Download (RFC 5545) mit Event-Details, optional webcal:// für Live-Updates.
* Stabile UID aus Event-Token (Re-Import aktualisiert statt dupliziert) * Stabile UID aus Event-Token (Re-Import aktualisiert statt dupliziert)
@@ -118,6 +137,19 @@ Badge/Indikator bei ungelesenen Organisator-Updates, rein clientseitig via local
Event-Seite zeigt QR-Code mit der öffentlichen Event-URL. Event-Seite zeigt QR-Code mit der öffentlichen Event-URL.
* Serverseitig generiert (kein externer QR-Service) * Serverseitig generiert (kein externer QR-Service)
* Download als SVG oder hochauflösendes PNG * 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 ### 020 PWA
Web App Manifest + Service Worker für Installierbarkeit und Offline-Caching. Web App Manifest + Service Worker für Installierbarkeit und Offline-Caching.
@@ -137,11 +169,26 @@ Organisator sucht Headerbild über integrierte Unsplash-Suche.
* Bild lokal gespeichert + Unsplash-Attribution * Bild lokal gespeichert + Unsplash-Attribution
* Feature deaktiviert wenn kein API-Key konfiguriert * 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 ### 026 404-Seite
Catch-all Route für ungültige Pfade mit "Seite nicht gefunden" und Link zur Startseite. Catch-all Route für ungültige Pfade mit "Seite nicht gefunden" und Link zur Startseite.
* Folgt dem Design System (Electric Dusk + Sora) * Folgt dem Design System (Electric Dusk + Sora)
* WCAG AA konform * WCAG AA konform
* Verhindert leere Seiten bei Fehlnavigation * 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?)

View File

@@ -1,37 +0,0 @@
# Modern UI Effects Research (2025-2026)
## Liquid Glass (Apple WWDC 2025)
Evolved glassmorphism with directional lighting. Three-layer approach: highlight, shadow, illumination.
- `backdrop-filter: blur(20px) saturate(1.5)` — higher saturation than basic glass
- `inset 0 1px 0 rgba(255,255,255,0.15)` — top highlight (light direction)
- `inset 0 -1px 0 rgba(0,0,0,0.1)` — bottom shadow
- Outer drop shadow for depth: `0 8px 32px rgba(0,0,0,0.3)`
- Advanced: SVG `feTurbulence` + `feSpecularLighting` for refraction (Chromium only)
- Browser support: `backdrop-filter` ~88%, Firefox since v103
## Aurora / Gradient Mesh Backgrounds
Stacked animated radial gradients simulating northern lights. Pairs well with glass cards on dark backgrounds.
- Multiple `radial-gradient(ellipse ...)` layers with partial opacity
- Animated via `background-position` shift (GPU-friendly)
- `@property` rule enables direct gradient color animation (broad support since 2024)
- Best for ambient background movement, not for content areas
## Animated Glow Borders
Rotating `conic-gradient` borders with blur halo. Striking on dark backgrounds.
- Outer wrapper with `conic-gradient(from var(--angle), color1, color2, color3, color1)`
- `::before` pseudo with `filter: blur(12px)` and `opacity: 0.5` for glow halo
- `@property --angle` trick to animate custom property inside `conic-gradient`
- Use sparingly — best for single highlight elements (FAB, CTA), not all cards
## Modern Neumorphism (2025-2026 revision)
Subtler than the original trend. Higher contrast, less extreme extrusion, combined with accent colors.
- Light and dark shadow pair: `6px 6px 12px rgba(0,0,0,0.5)` + `-6px -6px 12px rgba(60,50,80,0.15)`
- `border: 1px solid rgba(255,255,255,0.05)` for definition
- Works on dark backgrounds with slightly lighter "uplift" shadow direction
- Better suited for interactive elements (buttons, toggles) than content cards
## Sources
- Apple Liquid Glass CSS: dev.to/gruszdev, dev.to/kevinbism, css-tricks.com, kube.io
- Aurora: dev.to/oobleck, daltonwalsh.com, github.com/mattnewdavid
- Glow borders: frontendmasters.com (Kevin Powell), docode.co.in
- Trends overview: medium.com/design-bootcamp, index.dev, bighuman.com

View File

@@ -51,8 +51,10 @@ 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). - Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
## Active Technologies ## Active Technologies
- TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite (018-cancel-event-list) - Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
- localStorage via `useEventStorage()` composable (018-cancel-event-list) - 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)
## Recent Changes ## Recent Changes
- 018-cancel-event-list: Added TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite - 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

View File

@@ -1,5 +1,5 @@
# Stage 1: Build frontend # Stage 1: Build frontend
FROM node:25-alpine AS frontend-build FROM node:24-alpine AS frontend-build
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci

View File

@@ -1,25 +1,6 @@
<p align="center"> # fete
<img src="frontend/public/og-image.png" alt="fete" width="100%" />
</p>
<p align="center"> A privacy-focused, self-hostable web app for event announcements and RSVPs. An alternative to Facebook Events or Telegram groups — reduced to the essentials.
<strong>Privacy-focused, self-hostable event announcements and RSVPs.</strong><br>
An alternative to Facebook Events or Telegram groups — reduced to the essentials.
</p>
<p align="center">
<img src="docs/screenshots/01-create-event.png" alt="Create Event" width="230" />
&nbsp;&nbsp;&nbsp;
<img src="docs/screenshots/02-event-detail.png" alt="Event Detail" width="230" />
&nbsp;&nbsp;&nbsp;
<img src="docs/screenshots/03-rsvp.png" alt="RSVP" width="230" />
</p>
<p align="center">
<sub>Create events &middot; Share with guests &middot; Collect RSVPs</sub>
</p>
---
## What it does ## What it does

View File

@@ -1,3 +1,3 @@
wrapperVersion=3.3.4 wrapperVersion=3.3.4
distributionType=only-script distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip

View File

@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.12</version> <version>3.5.11</version>
<relativePath/> <relativePath/>
</parent> </parent>

View File

@@ -7,8 +7,4 @@
<Match> <Match>
<Package name="de.fete.adapter.in.web.model"/> <Package name="de.fete.adapter.in.web.model"/>
</Match> </Match>
<!-- Constructor-injected Spring beans storing interfaces/proxies are not a real exposure risk -->
<Match>
<Bug pattern="EI_EXPOSE_REP2"/>
</Match>
</FindBugsFilter> </FindBugsFilter>

View File

@@ -2,11 +2,9 @@ package de.fete;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/** Spring Boot entry point for the fete application. */ /** Spring Boot entry point for the fete application. */
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class FeteApplication { public class FeteApplication {
/** Starts the application. */ /** Starts the application. */

View File

@@ -8,23 +8,21 @@ import de.fete.adapter.in.web.model.CreateRsvpRequest;
import de.fete.adapter.in.web.model.CreateRsvpResponse; import de.fete.adapter.in.web.model.CreateRsvpResponse;
import de.fete.adapter.in.web.model.GetAttendeesResponse; import de.fete.adapter.in.web.model.GetAttendeesResponse;
import de.fete.adapter.in.web.model.GetEventResponse; import de.fete.adapter.in.web.model.GetEventResponse;
import de.fete.adapter.in.web.model.PatchEventRequest; import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.exception.EventNotFoundException; import de.fete.application.service.InvalidTimezoneException;
import de.fete.application.service.exception.InvalidTimezoneException;
import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken; import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken; import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp; 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.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase; import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.in.UpdateEventUseCase; import java.time.Clock;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -39,27 +37,24 @@ public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase; private final CreateEventUseCase createEventUseCase;
private final GetEventUseCase getEventUseCase; private final GetEventUseCase getEventUseCase;
private final CreateRsvpUseCase createRsvpUseCase; private final CreateRsvpUseCase createRsvpUseCase;
private final CancelRsvpUseCase cancelRsvpUseCase;
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase; private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
private final GetAttendeesUseCase getAttendeesUseCase; private final GetAttendeesUseCase getAttendeesUseCase;
private final UpdateEventUseCase updateEventUseCase; private final Clock clock;
/** Creates a new controller with the given use cases. */ /** Creates a new controller with the given use cases and clock. */
public EventController( public EventController(
CreateEventUseCase createEventUseCase, CreateEventUseCase createEventUseCase,
GetEventUseCase getEventUseCase, GetEventUseCase getEventUseCase,
CreateRsvpUseCase createRsvpUseCase, CreateRsvpUseCase createRsvpUseCase,
CancelRsvpUseCase cancelRsvpUseCase,
CountAttendeesByEventUseCase countAttendeesByEventUseCase, CountAttendeesByEventUseCase countAttendeesByEventUseCase,
GetAttendeesUseCase getAttendeesUseCase, GetAttendeesUseCase getAttendeesUseCase,
UpdateEventUseCase updateEventUseCase) { Clock clock) {
this.createEventUseCase = createEventUseCase; this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase; this.getEventUseCase = getEventUseCase;
this.createRsvpUseCase = createRsvpUseCase; this.createRsvpUseCase = createRsvpUseCase;
this.cancelRsvpUseCase = cancelRsvpUseCase;
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase; this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
this.getAttendeesUseCase = getAttendeesUseCase; this.getAttendeesUseCase = getAttendeesUseCase;
this.updateEventUseCase = updateEventUseCase; this.clock = clock;
} }
@Override @Override
@@ -72,61 +67,52 @@ public class EventController implements EventsApi {
request.getDescription(), request.getDescription(),
request.getDateTime(), request.getDateTime(),
zoneId, zoneId,
request.getLocation() request.getLocation(),
request.getExpiryDate()
); );
Event event = createEventUseCase.createEvent(command); Event event = createEventUseCase.createEvent(command);
var response = new CreateEventResponse(); var response = new CreateEventResponse();
response.setEventToken(event.eventToken().value()); response.setEventToken(event.getEventToken().value());
response.setOrganizerToken(event.organizerToken().value()); response.setOrganizerToken(event.getOrganizerToken().value());
response.setTitle(event.title()); response.setTitle(event.getTitle());
response.setDateTime(event.dateTime()); response.setDateTime(event.getDateTime());
response.setTimezone(event.timezone().getId()); response.setTimezone(event.getTimezone().getId());
response.setExpiryDate(event.getExpiryDate());
return ResponseEntity.status(HttpStatus.CREATED).body(response); return ResponseEntity.status(HttpStatus.CREATED).body(response);
} }
@Override @Override
public ResponseEntity<GetEventResponse> getEvent(UUID eventToken) { public ResponseEntity<GetEventResponse> getEvent(UUID token) {
var evtToken = new EventToken(eventToken); var eventToken = new de.fete.domain.model.EventToken(token);
Event event = getEventUseCase.getByEventToken(evtToken) Event event = getEventUseCase.getByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken)); .orElseThrow(() -> new EventNotFoundException(token));
var response = new GetEventResponse(); var response = new GetEventResponse();
response.setEventToken(event.eventToken().value()); response.setEventToken(event.getEventToken().value());
response.setTitle(event.title()); response.setTitle(event.getTitle());
response.setDescription(event.description()); response.setDescription(event.getDescription());
response.setDateTime(event.dateTime()); response.setDateTime(event.getDateTime());
response.setTimezone(event.timezone().getId()); response.setTimezone(event.getTimezone().getId());
response.setLocation(event.location()); response.setLocation(event.getLocation());
response.setAttendeeCount( response.setAttendeeCount(
(int) countAttendeesByEventUseCase.countByEvent(evtToken)); (int) countAttendeesByEventUseCase.countByEvent(eventToken));
response.setCancelled(event.cancelled()); response.setExpired(
response.setCancellationReason(event.cancellationReason()); event.getExpiryDate().isBefore(LocalDate.now(clock)));
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@Override
public ResponseEntity<Void> patchEvent(
UUID eventToken, UUID organizerToken, PatchEventRequest request) {
updateEventUseCase.cancelEvent(
new EventToken(eventToken),
new OrganizerToken(organizerToken),
request.getCancelled(),
request.getCancellationReason());
return ResponseEntity.noContent().build();
}
@Override @Override
public ResponseEntity<GetAttendeesResponse> getAttendees( public ResponseEntity<GetAttendeesResponse> getAttendees(
UUID eventToken, UUID organizerToken) { UUID token, UUID organizerToken) {
var evtToken = new EventToken(eventToken); var eventToken = new EventToken(token);
var orgToken = new OrganizerToken(organizerToken); var orgToken = new OrganizerToken(organizerToken);
List<String> names = getAttendeesUseCase List<String> names = getAttendeesUseCase
.getAttendeeNames(evtToken, orgToken); .getAttendeeNames(eventToken, orgToken);
var attendees = names.stream() var attendees = names.stream()
.map(name -> new Attendee().name(name)) .map(name -> new Attendee().name(name))
@@ -140,23 +126,17 @@ public class EventController implements EventsApi {
@Override @Override
public ResponseEntity<CreateRsvpResponse> createRsvp( public ResponseEntity<CreateRsvpResponse> createRsvp(
UUID eventToken, CreateRsvpRequest createRsvpRequest) { UUID token, CreateRsvpRequest createRsvpRequest) {
var evtToken = new EventToken(eventToken); var eventToken = new EventToken(token);
Rsvp rsvp = createRsvpUseCase.createRsvp(evtToken, createRsvpRequest.getName()); Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName());
var response = new CreateRsvpResponse(); var response = new CreateRsvpResponse();
response.setRsvpToken(rsvp.rsvpToken().value()); response.setRsvpToken(rsvp.getRsvpToken().value());
response.setName(rsvp.name()); response.setName(rsvp.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(response); 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) { private static ZoneId parseTimezone(String timezone) {
try { try {
return ZoneId.of(timezone); return ZoneId.of(timezone);

View File

@@ -1,13 +1,11 @@
package de.fete.adapter.in.web; package de.fete.adapter.in.web;
import de.fete.application.service.exception.EventAlreadyCancelledException; import de.fete.application.service.EventExpiredException;
import de.fete.application.service.exception.EventCancelledException; import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.exception.EventExpiredException; import de.fete.application.service.ExpiryDateBeforeEventException;
import de.fete.application.service.exception.EventNotFoundException; import de.fete.application.service.ExpiryDateInPastException;
import de.fete.application.service.exception.ExpiryDateBeforeEventException; import de.fete.application.service.InvalidOrganizerTokenException;
import de.fete.application.service.exception.ExpiryDateInPastException; import de.fete.application.service.InvalidTimezoneException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.application.service.exception.InvalidTimezoneException;
import java.net.URI; import java.net.URI;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -77,32 +75,6 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail); .body(problemDetail);
} }
/** Handles attempt to cancel an already cancelled event. */
@ExceptionHandler(EventAlreadyCancelledException.class)
public ResponseEntity<ProblemDetail> handleEventAlreadyCancelled(
EventAlreadyCancelledException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("Event Already Cancelled");
problemDetail.setType(URI.create("urn:problem-type:event-already-cancelled"));
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles RSVP on cancelled event. */
@ExceptionHandler(EventCancelledException.class)
public ResponseEntity<ProblemDetail> handleEventCancelled(
EventCancelledException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("Event Cancelled");
problemDetail.setType(URI.create("urn:problem-type:event-cancelled"));
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles RSVP on expired event. */ /** Handles RSVP on expired event. */
@ExceptionHandler(EventExpiredException.class) @ExceptionHandler(EventExpiredException.class)
public ResponseEntity<ProblemDetail> handleEventExpired( public ResponseEntity<ProblemDetail> handleEventExpired(

View File

@@ -1,188 +0,0 @@
package de.fete.adapter.in.web;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.port.in.GetEventUseCase;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
/** Serves the SPA index.html with injected Open Graph and Twitter Card meta-tags. */
@Controller
public class SpaController {
private static final String PLACEHOLDER = "<!-- OG_META_TAGS -->";
private static final int MAX_TITLE_LENGTH = 70;
private static final int MAX_DESCRIPTION_LENGTH = 200;
private static final String GENERIC_TITLE = "fete";
private static final String GENERIC_DESCRIPTION =
"Privacy-focused event planning. Create and share events without accounts.";
private static final DateTimeFormatter DATE_FORMAT =
DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH);
private final GetEventUseCase getEventUseCase;
private String htmlTemplate;
/** Creates a new SpaController. */
public SpaController(GetEventUseCase getEventUseCase) {
this.getEventUseCase = getEventUseCase;
}
/** Loads and caches the index.html template at startup. */
@PostConstruct
void loadTemplate() throws IOException {
var resource = new ClassPathResource("/static/index.html");
if (resource.exists()) {
htmlTemplate = resource.getContentAsString(StandardCharsets.UTF_8);
}
}
/** Serves SPA HTML with generic meta-tags for non-event routes. */
@GetMapping(
value = {"/", "/create", "/events"},
produces = MediaType.TEXT_HTML_VALUE
)
@ResponseBody
public String serveGenericPage(HttpServletRequest request) {
if (htmlTemplate == null) {
return "";
}
String baseUrl = getBaseUrl(request);
return htmlTemplate.replace(PLACEHOLDER, renderTags(buildGenericMeta(baseUrl)));
}
/** Serves SPA HTML with event-specific meta-tags. */
@GetMapping(
value = "/events/{eventToken}",
produces = MediaType.TEXT_HTML_VALUE
)
@ResponseBody
public String serveEventPage(@PathVariable String eventToken,
HttpServletRequest request) {
if (htmlTemplate == null) {
return "";
}
String baseUrl = getBaseUrl(request);
Map<String, String> meta = resolveEventMeta(eventToken, baseUrl);
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
}
// --- Meta-tag composition ---
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
var tags = new LinkedHashMap<String, String>();
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.eventToken().value());
tags.put("og:type", "website");
tags.put("og:site_name", GENERIC_TITLE);
tags.put("og:image", baseUrl + "/og-image.png");
tags.put("twitter:card", "summary");
tags.put("twitter:title", title);
tags.put("twitter:description", description);
return tags;
}
private Map<String, String> buildGenericMeta(String baseUrl) {
var tags = new LinkedHashMap<String, String>();
tags.put("og:title", GENERIC_TITLE);
tags.put("og:description", GENERIC_DESCRIPTION);
tags.put("og:url", baseUrl);
tags.put("og:type", "website");
tags.put("og:site_name", GENERIC_TITLE);
tags.put("og:image", baseUrl + "/og-image.png");
tags.put("twitter:card", "summary");
tags.put("twitter:title", GENERIC_TITLE);
tags.put("twitter:description", GENERIC_DESCRIPTION);
return tags;
}
private Map<String, String> resolveEventMeta(String token, String baseUrl) {
try {
UUID uuid = UUID.fromString(token);
Optional<Event> event =
getEventUseCase.getByEventToken(new EventToken(uuid));
if (event.isPresent()) {
return buildEventMeta(event.get(), baseUrl);
}
} catch (IllegalArgumentException ignored) {
// Invalid UUID — fall back to generic
}
return buildGenericMeta(baseUrl);
}
// --- Description formatting ---
private String truncateTitle(String title) {
if (title.length() <= MAX_TITLE_LENGTH) {
return title;
}
return title.substring(0, MAX_TITLE_LENGTH - 3) + "...";
}
private String formatDescription(Event event) {
ZonedDateTime zoned = event.dateTime().atZoneSameInstant(event.timezone());
var sb = new StringBuilder();
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
if (event.location() != null && !event.location().isBlank()) {
sb.append(" · 📍 ").append(event.location());
}
if (event.description() != null && !event.description().isBlank()) {
sb.append("").append(event.description());
}
String result = sb.toString();
if (result.length() > MAX_DESCRIPTION_LENGTH) {
return result.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
}
return result;
}
// --- HTML rendering ---
private String renderTags(Map<String, String> tags) {
var sb = new StringBuilder();
for (var entry : tags.entrySet()) {
String key = entry.getKey();
String value = escapeHtml(entry.getValue());
String attr = key.startsWith("twitter:") ? "name" : "property";
sb.append("<meta ").append(attr).append("=\"").append(key)
.append("\" content=\"").append(value).append("\">\n");
}
return sb.toString().stripTrailing();
}
private String escapeHtml(String input) {
return input
.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;");
}
private String getBaseUrl(HttpServletRequest request) {
return ServletUriComponentsBuilder.fromRequestUri(request)
.replacePath("")
.build()
.toUriString();
}
}

View File

@@ -46,12 +46,6 @@ public class EventJpaEntity {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@Column(name = "cancelled", nullable = false)
private boolean cancelled;
@Column(name = "cancellation_reason", length = 2000)
private String cancellationReason;
/** Returns the internal database ID. */ /** Returns the internal database ID. */
public Long getId() { public Long getId() {
return id; return id;
@@ -151,24 +145,4 @@ public class EventJpaEntity {
public void setCreatedAt(OffsetDateTime createdAt) { public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
/** Returns whether the event is cancelled. */
public boolean isCancelled() {
return cancelled;
}
/** Sets the cancelled flag. */
public void setCancelled(boolean cancelled) {
this.cancelled = cancelled;
}
/** Returns the cancellation reason. */
public String getCancellationReason() {
return cancellationReason;
}
/** Sets the cancellation reason. */
public void setCancellationReason(String cancellationReason) {
this.cancellationReason = cancellationReason;
}
} }

View File

@@ -3,17 +3,10 @@ package de.fete.adapter.out.persistence;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository; 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. */ /** Spring Data JPA repository for event entities. */
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> { public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
/** Finds an event by its public event token. */ /** Finds an event by its public event token. */
Optional<EventJpaEntity> findByEventToken(UUID eventToken); 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();
} }

View File

@@ -31,41 +31,33 @@ public class EventPersistenceAdapter implements EventRepository {
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain); return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
} }
@Override
public int deleteExpired() {
return jpaRepository.deleteExpired();
}
private EventJpaEntity toEntity(Event event) { private EventJpaEntity toEntity(Event event) {
var entity = new EventJpaEntity(); var entity = new EventJpaEntity();
entity.setId(event.id()); entity.setId(event.getId());
entity.setEventToken(event.eventToken().value()); entity.setEventToken(event.getEventToken().value());
entity.setOrganizerToken(event.organizerToken().value()); entity.setOrganizerToken(event.getOrganizerToken().value());
entity.setTitle(event.title()); entity.setTitle(event.getTitle());
entity.setDescription(event.description()); entity.setDescription(event.getDescription());
entity.setDateTime(event.dateTime()); entity.setDateTime(event.getDateTime());
entity.setTimezone(event.timezone().getId()); entity.setTimezone(event.getTimezone().getId());
entity.setLocation(event.location()); entity.setLocation(event.getLocation());
entity.setExpiryDate(event.expiryDate()); entity.setExpiryDate(event.getExpiryDate());
entity.setCreatedAt(event.createdAt()); entity.setCreatedAt(event.getCreatedAt());
entity.setCancelled(event.cancelled());
entity.setCancellationReason(event.cancellationReason());
return entity; return entity;
} }
private Event toDomain(EventJpaEntity entity) { private Event toDomain(EventJpaEntity entity) {
return new Event( var event = new Event();
entity.getId(), event.setId(entity.getId());
new EventToken(entity.getEventToken()), event.setEventToken(new EventToken(entity.getEventToken()));
new OrganizerToken(entity.getOrganizerToken()), event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
entity.getTitle(), event.setTitle(entity.getTitle());
entity.getDescription(), event.setDescription(entity.getDescription());
entity.getDateTime(), event.setDateTime(entity.getDateTime());
ZoneId.of(entity.getTimezone()), event.setTimezone(ZoneId.of(entity.getTimezone()));
entity.getLocation(), event.setLocation(entity.getLocation());
entity.getExpiryDate(), event.setExpiryDate(entity.getExpiryDate());
entity.getCreatedAt(), event.setCreatedAt(entity.getCreatedAt());
entity.isCancelled(), return event;
entity.getCancellationReason());
} }
} }

View File

@@ -14,7 +14,4 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
/** Finds all RSVPs for the given event, ordered by ID ascending. */ /** Finds all RSVPs for the given event, ordered by ID ascending. */
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId); 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);
} }

View File

@@ -36,25 +36,21 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
.toList(); .toList();
} }
@Override
public boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken) {
return jpaRepository.deleteByEventIdAndRsvpToken(eventId, rsvpToken.value()) > 0;
}
private RsvpJpaEntity toEntity(Rsvp rsvp) { private RsvpJpaEntity toEntity(Rsvp rsvp) {
var entity = new RsvpJpaEntity(); var entity = new RsvpJpaEntity();
entity.setId(rsvp.id()); entity.setId(rsvp.getId());
entity.setRsvpToken(rsvp.rsvpToken().value()); entity.setRsvpToken(rsvp.getRsvpToken().value());
entity.setEventId(rsvp.eventId()); entity.setEventId(rsvp.getEventId());
entity.setName(rsvp.name()); entity.setName(rsvp.getName());
return entity; return entity;
} }
private Rsvp toDomain(RsvpJpaEntity entity) { private Rsvp toDomain(RsvpJpaEntity entity) {
return new Rsvp( var rsvp = new Rsvp();
entity.getId(), rsvp.setId(entity.getId());
new RsvpToken(entity.getRsvpToken()), rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken()));
entity.getEventId(), rsvp.setEventId(entity.getEventId());
entity.getName()); rsvp.setName(entity.getName());
return rsvp;
} }
} }

View File

@@ -1,4 +1,4 @@
package de.fete.application.service.exception; package de.fete.application.service;
import java.util.UUID; import java.util.UUID;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service.exception; package de.fete.application.service;
import java.util.UUID; import java.util.UUID;

View File

@@ -1,28 +1,21 @@
package de.fete.application.service; package de.fete.application.service;
import de.fete.application.service.exception.EventAlreadyCancelledException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken; import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken; import de.fete.domain.model.OrganizerToken;
import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.in.UpdateEventUseCase;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Optional; import java.util.Optional;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Application service implementing event creation and retrieval. */ /** Application service implementing event creation and retrieval. */
@Service @Service
public class EventService implements CreateEventUseCase, GetEventUseCase, UpdateEventUseCase { public class EventService implements CreateEventUseCase, GetEventUseCase {
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
private final EventRepository eventRepository; private final EventRepository eventRepository;
private final Clock clock; private final Clock clock;
@@ -35,21 +28,24 @@ public class EventService implements CreateEventUseCase, GetEventUseCase, Update
@Override @Override
public Event createEvent(CreateEventCommand command) { public Event createEvent(CreateEventCommand command) {
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT); if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
throw new ExpiryDateInPastException(command.expiryDate());
}
var event = new Event( if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) {
null, throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
EventToken.generate(), }
OrganizerToken.generate(),
command.title(), var event = new Event();
command.description(), event.setEventToken(EventToken.generate());
command.dateTime(), event.setOrganizerToken(OrganizerToken.generate());
command.timezone(), event.setTitle(command.title());
command.location(), event.setDescription(command.description());
expiryDate, event.setDateTime(command.dateTime());
OffsetDateTime.now(clock), event.setTimezone(command.timezone());
false, event.setLocation(command.location());
null); event.setExpiryDate(command.expiryDate());
event.setCreatedAt(OffsetDateTime.now(clock));
return eventRepository.save(event); return eventRepository.save(event);
} }
@@ -58,27 +54,4 @@ public class EventService implements CreateEventUseCase, GetEventUseCase, Update
public Optional<Event> getByEventToken(EventToken eventToken) { public Optional<Event> getByEventToken(EventToken eventToken) {
return eventRepository.findByEventToken(eventToken); return eventRepository.findByEventToken(eventToken);
} }
@Transactional
@Override
public void cancelEvent(
EventToken eventToken, OrganizerToken organizerToken,
Boolean cancelled, String reason) {
if (!Boolean.TRUE.equals(cancelled)) {
return;
}
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.organizerToken().equals(organizerToken)) {
throw new InvalidOrganizerTokenException();
}
if (event.cancelled()) {
throw new EventAlreadyCancelledException(eventToken.value());
}
eventRepository.save(event.withCancellation(true, reason));
}
} }

View File

@@ -1,30 +0,0 @@
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);
}
}

View File

@@ -1,4 +1,4 @@
package de.fete.application.service.exception; package de.fete.application.service;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service.exception; package de.fete.application.service;
import java.time.LocalDate; import java.time.LocalDate;

View File

@@ -1,4 +1,4 @@
package de.fete.application.service.exception; package de.fete.application.service;
/** Thrown when an invalid organizer token is provided. */ /** Thrown when an invalid organizer token is provided. */
public class InvalidOrganizerTokenException extends RuntimeException { public class InvalidOrganizerTokenException extends RuntimeException {

View File

@@ -1,4 +1,4 @@
package de.fete.application.service.exception; package de.fete.application.service;
/** Thrown when an invalid IANA timezone ID is provided. */ /** Thrown when an invalid IANA timezone ID is provided. */
public class InvalidTimezoneException extends RuntimeException { public class InvalidTimezoneException extends RuntimeException {

View File

@@ -1,21 +1,15 @@
package de.fete.application.service; package de.fete.application.service;
import de.fete.application.service.exception.EventCancelledException;
import de.fete.application.service.exception.EventExpiredException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken; import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken; import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp; import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken; 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.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase; import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository; import de.fete.domain.port.out.RsvpRepository;
import jakarta.transaction.Transactional;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
@@ -24,8 +18,7 @@ import org.springframework.stereotype.Service;
/** Application service implementing RSVP operations. */ /** Application service implementing RSVP operations. */
@Service @Service
public class RsvpService public class RsvpService
implements CreateRsvpUseCase, CancelRsvpUseCase, CountAttendeesByEventUseCase, implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase {
GetAttendeesUseCase {
private final EventRepository eventRepository; private final EventRepository eventRepository;
private final RsvpRepository rsvpRepository; private final RsvpRepository rsvpRepository;
@@ -46,32 +39,23 @@ public class RsvpService
Event event = eventRepository.findByEventToken(eventToken) Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value())); .orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (event.cancelled()) { if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
throw new EventCancelledException(eventToken.value());
}
if (!event.expiryDate().isAfter(LocalDate.now(clock))) {
throw new EventExpiredException(eventToken.value()); throw new EventExpiredException(eventToken.value());
} }
var rsvp = new Rsvp(null, RsvpToken.generate(), event.id(), name.strip()); var rsvp = new Rsvp();
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(event.getId());
rsvp.setName(name.strip());
return rsvpRepository.save(rsvp); return rsvpRepository.save(rsvp);
} }
@Override
@Transactional
public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) {
eventRepository.findByEventToken(eventToken)
.ifPresent(event ->
rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken));
}
@Override @Override
public long countByEvent(EventToken eventToken) { public long countByEvent(EventToken eventToken) {
Event event = eventRepository.findByEventToken(eventToken) Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value())); .orElseThrow(() -> new EventNotFoundException(eventToken.value()));
return rsvpRepository.countByEventId(event.id()); return rsvpRepository.countByEventId(event.getId());
} }
@Override @Override
@@ -79,12 +63,12 @@ public class RsvpService
Event event = eventRepository.findByEventToken(eventToken) Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value())); .orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.organizerToken().equals(organizerToken)) { if (!event.getOrganizerToken().equals(organizerToken)) {
throw new InvalidOrganizerTokenException(); throw new InvalidOrganizerTokenException();
} }
return rsvpRepository.findByEventId(event.id()).stream() return rsvpRepository.findByEventId(event.getId()).stream()
.map(Rsvp::name) .map(Rsvp::getName)
.toList(); .toList();
} }
} }

View File

@@ -1,12 +0,0 @@
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);
}
}

View File

@@ -1,12 +0,0 @@
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);
}
}

View File

@@ -1,4 +0,0 @@
/**
* Application-layer exceptions thrown by service use case implementations.
*/
package de.fete.application.service.exception;

View File

@@ -1,17 +1,21 @@
package de.fete.config; package de.fete.config;
import java.io.IOException;
import java.time.Clock; import java.time.Clock;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
/** Configures API path prefix. Static resources served by default Spring Boot handler. */ /** Configures API path prefix and SPA static resource serving. */
@Configuration @Configuration
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
/** Provides a system clock bean for time-dependent services. */
@Bean @Bean
Clock clock() { Clock clock() {
return Clock.systemDefaultZone(); return Clock.systemDefaultZone();
@@ -21,4 +25,23 @@ public class WebConfig implements WebMvcConfigurer {
public void configurePathMatch(PathMatchConfigurer configurer) { public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class)); configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
} }
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(String resourcePath,
Resource location) throws IOException {
Resource requested = location.createRelative(resourcePath);
if (requested.exists() && requested.isReadable()) {
return requested;
}
Resource index = new ClassPathResource("/static/index.html");
return (index.exists() && index.isReadable()) ? index : null;
}
});
}
} }

View File

@@ -10,5 +10,6 @@ public record CreateEventCommand(
String description, String description,
OffsetDateTime dateTime, OffsetDateTime dateTime,
ZoneId timezone, ZoneId timezone,
String location String location,
LocalDate expiryDate
) {} ) {}

View File

@@ -5,26 +5,116 @@ import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
/** Domain entity representing an event. */ /** Domain entity representing an event. */
public record Event( public class 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
) {
/** Returns a copy of this event with cancellation applied. */ private Long id;
public Event withCancellation(boolean cancelled, String cancellationReason) { private EventToken eventToken;
return new Event( private OrganizerToken organizerToken;
id, eventToken, organizerToken, title, description, private String title;
dateTime, timezone, location, expiryDate, createdAt, private String description;
cancelled, cancellationReason); 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;
} }
} }

View File

@@ -1,9 +1,50 @@
package de.fete.domain.model; package de.fete.domain.model;
/** Domain entity representing an RSVP. */ /** Domain entity representing an RSVP. */
public record Rsvp( public class Rsvp {
Long id,
RsvpToken rsvpToken, private Long id;
Long eventId, private RsvpToken rsvpToken;
String name 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;
}
}

View File

@@ -1,11 +0,0 @@
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);
}

View File

@@ -1,13 +0,0 @@
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);
}

View File

@@ -12,7 +12,4 @@ public interface EventRepository {
/** Finds an event by its public event token. */ /** Finds an event by its public event token. */
Optional<Event> findByEventToken(EventToken eventToken); Optional<Event> findByEventToken(EventToken eventToken);
/** Deletes all events whose expiry date is in the past. Returns the number of deleted events. */
int deleteExpired();
} }

View File

@@ -1,7 +1,6 @@
package de.fete.domain.port.out; package de.fete.domain.port.out;
import de.fete.domain.model.Rsvp; import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import java.util.List; import java.util.List;
/** Outbound port for persisting and querying RSVPs. */ /** Outbound port for persisting and querying RSVPs. */
@@ -15,7 +14,4 @@ public interface RsvpRepository {
/** Finds all RSVPs for the given event, ordered by ID ascending. */ /** Finds all RSVPs for the given event, ordered by ID ascending. */
List<Rsvp> findByEventId(Long eventId); 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);
} }

View File

@@ -7,9 +7,6 @@ spring.jpa.open-in-view=false
# Liquibase # Liquibase
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
# Proxy headers
server.forward-headers-strategy=framework
# Actuator # Actuator
management.endpoints.web.exposure.include=health management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never management.endpoint.health.show-details=never

View File

@@ -1,17 +0,0 @@
<?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>

View File

@@ -9,6 +9,5 @@
<include file="db/changelog/001-create-events-table.xml"/> <include file="db/changelog/001-create-events-table.xml"/>
<include file="db/changelog/002-add-timezone-column.xml"/> <include file="db/changelog/002-add-timezone-column.xml"/>
<include file="db/changelog/003-create-rsvps-table.xml"/> <include file="db/changelog/003-create-rsvps-table.xml"/>
<include file="db/changelog/004-add-cancellation-columns.xml"/>
</databaseChangeLog> </databaseChangeLog>

View File

@@ -37,46 +37,14 @@ paths:
schema: schema:
$ref: "#/components/schemas/ValidationProblemDetail" $ref: "#/components/schemas/ValidationProblemDetail"
/events/{eventToken}/rsvps/{rsvpToken}: /events/{token}/rsvps:
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: post:
operationId: createRsvp operationId: createRsvp
summary: Submit an RSVP for an event summary: Submit an RSVP for an event
tags: tags:
- events - events
parameters: parameters:
- name: eventToken - name: token
in: path in: path
required: true required: true
schema: schema:
@@ -115,14 +83,14 @@ paths:
schema: schema:
$ref: "#/components/schemas/ProblemDetail" $ref: "#/components/schemas/ProblemDetail"
/events/{eventToken}/attendees: /events/{token}/attendees:
get: get:
operationId: getAttendees operationId: getAttendees
summary: Get attendee list for an event (organizer only) summary: Get attendee list for an event (organizer only)
tags: tags:
- events - events
parameters: parameters:
- name: eventToken - name: token
in: path in: path
required: true required: true
schema: schema:
@@ -156,14 +124,14 @@ paths:
schema: schema:
$ref: "#/components/schemas/ProblemDetail" $ref: "#/components/schemas/ProblemDetail"
/events/{eventToken}: /events/{token}:
get: get:
operationId: getEvent operationId: getEvent
summary: Get public event details by token summary: Get public event details by token
tags: tags:
- events - events
parameters: parameters:
- name: eventToken - name: token
in: path in: path
required: true required: true
schema: schema:
@@ -184,58 +152,6 @@ paths:
schema: schema:
$ref: "#/components/schemas/ProblemDetail" $ref: "#/components/schemas/ProblemDetail"
patch:
operationId: patchEvent
summary: Update an event (currently cancel)
description: |
Partial update of an event resource. Currently the only supported operation
is cancellation (setting cancelled to true). Requires the organizer token.
Cancellation is irreversible.
tags:
- events
parameters:
- name: eventToken
in: path
required: true
schema:
type: string
format: uuid
description: Public event token
- name: organizerToken
in: query
required: true
schema:
type: string
format: uuid
description: Organizer token for authorization
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PatchEventRequest"
responses:
"204":
description: Event updated successfully
"403":
description: Invalid organizer token
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: Event not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"409":
description: Event is already cancelled
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
components: components:
schemas: schemas:
CreateEventRequest: CreateEventRequest:
@@ -244,6 +160,7 @@ components:
- title - title
- dateTime - dateTime
- timezone - timezone
- expiryDate
properties: properties:
title: title:
type: string type: string
@@ -264,6 +181,11 @@ components:
location: location:
type: string type: string
maxLength: 500 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: CreateEventResponse:
type: object type: object
@@ -273,6 +195,7 @@ components:
- title - title
- dateTime - dateTime
- timezone - timezone
- expiryDate
properties: properties:
eventToken: eventToken:
type: string type: string
@@ -295,6 +218,10 @@ components:
type: string type: string
description: IANA timezone of the organizer description: IANA timezone of the organizer
example: "Europe/Berlin" example: "Europe/Berlin"
expiryDate:
type: string
format: date
example: "2026-06-15"
GetEventResponse: GetEventResponse:
type: object type: object
@@ -304,7 +231,7 @@ components:
- dateTime - dateTime
- timezone - timezone
- attendeeCount - attendeeCount
- cancelled - expired
properties: properties:
eventToken: eventToken:
type: string type: string
@@ -337,31 +264,10 @@ components:
minimum: 0 minimum: 0
description: Number of confirmed attendees (attending=true) description: Number of confirmed attendees (attending=true)
example: 12 example: 12
cancelled: expired:
type: boolean type: boolean
description: Whether the event has been cancelled description: Whether the event's expiry date has passed
example: false example: false
cancellationReason:
type:
- string
- "null"
description: Reason for cancellation, if provided
example: null
PatchEventRequest:
type: object
required:
- cancelled
properties:
cancelled:
type: boolean
description: Set to true to cancel the event (irreversible)
example: true
cancellationReason:
type: string
maxLength: 2000
description: Optional cancellation reason
example: "Unfortunately the venue is no longer available."
CreateRsvpRequest: CreateRsvpRequest:
type: object type: object

View File

@@ -4,14 +4,10 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.onionArchitecture; import static com.tngtech.archunit.library.Architectures.onionArchitecture;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class) @AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
class HexagonalArchitectureTest { class HexagonalArchitectureTest {
@@ -69,24 +65,4 @@ class HexagonalArchitectureTest {
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses() static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
.that().resideInAPackage("de.fete.adapter.in.web..") .that().resideInAPackage("de.fete.adapter.in.web..")
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out.."); .should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
@ArchTest
static final ArchRule domainModelsMustBeRecords = classes()
.that().resideInAPackage("de.fete.domain.model..")
.and().doNotHaveSimpleName("package-info")
.should(beRecords());
private static ArchCondition<JavaClass> beRecords() {
return new ArchCondition<>("be records") {
@Override
public void check(JavaClass javaClass,
ConditionEvents events) {
boolean isRecord = javaClass.reflect().isRecord();
if (!isRecord) {
events.add(SimpleConditionEvent.violated(javaClass,
javaClass.getFullName() + " is not a record"));
}
}
};
}
} }

View File

@@ -1,9 +1,7 @@
package de.fete.adapter.in.web; package de.fete.adapter.in.web;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -22,7 +20,6 @@ import de.fete.adapter.out.persistence.RsvpJpaRepository;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -58,7 +55,8 @@ class EventControllerIntegrationTest {
.description("Come celebrate!") .description("Come celebrate!")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin") .timezone("Europe/Berlin")
.location("Berlin"); .location("Berlin")
.expiryDate(LocalDate.of(2026, 6, 16));
var result = mockMvc.perform(post("/api/events") var result = mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -69,6 +67,7 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.title").value("Birthday Party")) .andExpect(jsonPath("$.title").value("Birthday Party"))
.andExpect(jsonPath("$.timezone").value("Europe/Berlin")) .andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
.andExpect(jsonPath("$.dateTime").isNotEmpty()) .andExpect(jsonPath("$.dateTime").isNotEmpty())
.andExpect(jsonPath("$.expiryDate").isNotEmpty())
.andReturn(); .andReturn();
var response = objectMapper.readValue( var response = objectMapper.readValue(
@@ -80,7 +79,7 @@ class EventControllerIntegrationTest {
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!"); assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin"); assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
assertThat(persisted.getLocation()).isEqualTo("Berlin"); assertThat(persisted.getLocation()).isEqualTo("Berlin");
assertThat(persisted.getExpiryDate()).isEqualTo(LocalDate.of(2026, 6, 22)); assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate());
assertThat(persisted.getDateTime().toInstant()) assertThat(persisted.getDateTime().toInstant())
.isEqualTo(request.getDateTime().toInstant()); .isEqualTo(request.getDateTime().toInstant());
assertThat(persisted.getOrganizerToken()).isNotNull(); assertThat(persisted.getOrganizerToken()).isNotNull();
@@ -92,7 +91,8 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest() var request = new CreateEventRequest()
.title("Minimal Event") .title("Minimal Event")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("UTC"); .timezone("UTC")
.expiryDate(LocalDate.of(2026, 6, 16));
var result = mockMvc.perform(post("/api/events") var result = mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -119,7 +119,8 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest() var request = new CreateEventRequest()
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin"); .timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 16));
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -138,6 +139,26 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest() var request = new CreateEventRequest()
.title("No Date") .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"); .timezone("Europe/Berlin");
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
@@ -150,12 +171,93 @@ class EventControllerIntegrationTest {
assertThat(jpaRepository.count()).isEqualTo(countBefore); 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 @Test
void errorResponseContentTypeIsProblemJson() throws Exception { void errorResponseContentTypeIsProblemJson() throws Exception {
var request = new CreateEventRequest() var request = new CreateEventRequest()
.title("") .title("")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Europe/Berlin"); .timezone("Europe/Berlin")
.expiryDate(LocalDate.of(2026, 6, 16));
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -171,7 +273,8 @@ class EventControllerIntegrationTest {
var request = new CreateEventRequest() var request = new CreateEventRequest()
.title("Bad TZ") .title("Bad TZ")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Not/A/Zone"); .timezone("Not/A/Zone")
.expiryDate(LocalDate.of(2026, 6, 16));
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -199,6 +302,7 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.timezone").value("Europe/Berlin")) .andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
.andExpect(jsonPath("$.location").value("Central Park")) .andExpect(jsonPath("$.location").value("Central Park"))
.andExpect(jsonPath("$.attendeeCount").value(0)) .andExpect(jsonPath("$.attendeeCount").value(0))
.andExpect(jsonPath("$.expired").value(false))
.andExpect(jsonPath("$.dateTime").isNotEmpty()); .andExpect(jsonPath("$.dateTime").isNotEmpty());
} }
@@ -223,6 +327,18 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found")); .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 --- // --- RSVP tests ---
@Test @Test
@@ -377,213 +493,6 @@ class EventControllerIntegrationTest {
"application/problem+json")); "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) { private void seedRsvp(EventJpaEntity event, String name) {
var rsvp = new RsvpJpaEntity(); var rsvp = new RsvpJpaEntity();
rsvp.setRsvpToken(UUID.randomUUID()); rsvp.setRsvpToken(UUID.randomUUID());

View File

@@ -1,281 +0,0 @@
package de.fete.adapter.in.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.fete.TestcontainersConfig;
import de.fete.adapter.out.persistence.EventJpaEntity;
import de.fete.adapter.out.persistence.EventJpaRepository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
class SpaControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private EventJpaRepository eventJpaRepository;
// --- Phase 2: Base functionality ---
@Test
void rootServesHtml() throws Exception {
mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
@Test
void rootHtmlDoesNotContainPlaceholder() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).doesNotContain("<!-- OG_META_TAGS -->");
}
@Test
void createRouteServesHtml() throws Exception {
mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
@Test
void eventsRouteServesHtml() throws Exception {
mockMvc.perform(get("/events").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
// --- Phase 4 (US2): Generic OG meta-tags ---
@Test
void rootContainsGenericOgTitle() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("content=\"fete\"");
}
@Test
void createRouteContainsGenericOgDescription() throws Exception {
String html = mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:description");
assertThat(html).contains("Privacy-focused event planning");
}
@Test
void unknownRouteReturns404() throws Exception {
mockMvc.perform(get("/unknown/path").accept(MediaType.TEXT_HTML))
.andExpect(status().isNotFound());
}
// --- Phase 5 (US3): Twitter Card meta-tags ---
@Test
void eventRouteContainsTwitterCardTags() throws Exception {
EventJpaEntity event = seedEvent(
"Twitter Test", "Testing cards",
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("twitter:card");
assertThat(html).contains("twitter:title");
assertThat(html).contains("twitter:description");
}
@Test
void genericRouteContainsTwitterCardTags() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("twitter:card");
assertThat(html).contains("content=\"summary\"");
}
// --- Phase 3 (US1): Event-specific OG meta-tags ---
@Test
void eventRouteContainsEventSpecificOgTitle() throws Exception {
EventJpaEntity event = seedEvent(
"Birthday Party", "Come celebrate!",
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("Birthday Party");
}
@Test
void eventRouteContainsOgDescription() throws Exception {
EventJpaEntity event = seedEvent(
"BBQ", "Bring drinks!",
"Europe/Berlin", "Central Park", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:description");
assertThat(html).contains("Central Park");
assertThat(html).contains("Bring drinks!");
}
@Test
void eventRouteContainsOgUrl() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null,
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:url");
assertThat(html).contains("/events/" + event.getEventToken());
}
@Test
void eventRouteContainsOgImage() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null,
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:image");
assertThat(html).contains("/og-image.png");
}
@Test
void unknownEventTokenFallsBackToGenericMeta() throws Exception {
String html = mockMvc.perform(
get("/events/" + UUID.randomUUID()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("content=\"fete\"");
}
// --- HTML escaping ---
@Test
void specialCharactersAreHtmlEscaped() throws Exception {
EventJpaEntity event = seedEvent(
"Tom & Jerry's \"Party\"", "Fun <times> & more",
"Europe/Berlin", "O'Brien's Pub", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("Tom &amp; Jerry");
assertThat(html).contains("&amp; more");
assertThat(html).contains("&lt;times&gt;");
assertThat(html).doesNotContain("content=\"Tom & Jerry");
}
// --- Title truncation ---
@Test
void longTitleIsTruncatedTo70Chars() throws Exception {
String longTitle = "A".repeat(80);
EventJpaEntity event = seedEvent(
longTitle, "Desc",
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("A".repeat(67) + "...");
assertThat(html).doesNotContain("A".repeat(68));
}
// --- Description formatting ---
@Test
void eventWithoutLocationOmitsPinEmoji() throws Exception {
EventJpaEntity event = seedEvent(
"Online Meetup", "Virtual gathering",
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).doesNotContain("📍");
}
@Test
void eventWithoutDescriptionOmitsDash() throws Exception {
EventJpaEntity event = seedEvent(
"Silent Event", null,
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("📅");
assertThat(html).contains("Berlin");
assertThat(html).doesNotContain("");
}
private EventJpaEntity seedEvent(
String title, String description, String timezone,
String location, LocalDate expiryDate) {
var entity = new EventJpaEntity();
entity.setEventToken(UUID.randomUUID());
entity.setOrganizerToken(UUID.randomUUID());
entity.setTitle(title);
entity.setDescription(description);
entity.setDateTime(
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
entity.setTimezone(timezone);
entity.setLocation(location);
entity.setExpiryDate(expiryDate);
entity.setCreatedAt(OffsetDateTime.now());
return eventJpaRepository.save(entity);
}
}

View File

@@ -1,83 +0,0 @@
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);
}
}

View File

@@ -30,8 +30,8 @@ class EventPersistenceAdapterTest {
Event saved = eventRepository.save(event); Event saved = eventRepository.save(event);
assertThat(saved.id()).isNotNull(); assertThat(saved.getId()).isNotNull();
assertThat(saved.title()).isEqualTo("Test Event"); assertThat(saved.getTitle()).isEqualTo("Test Event");
} }
@Test @Test
@@ -39,11 +39,11 @@ class EventPersistenceAdapterTest {
Event event = buildEvent(); Event event = buildEvent();
Event saved = eventRepository.save(event); Event saved = eventRepository.save(event);
Optional<Event> found = eventRepository.findByEventToken(saved.eventToken()); Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
assertThat(found).isPresent(); assertThat(found).isPresent();
assertThat(found.get().title()).isEqualTo("Test Event"); assertThat(found.get().getTitle()).isEqualTo("Test Event");
assertThat(found.get().id()).isEqualTo(saved.id()); assertThat(found.get().getId()).isEqualTo(saved.getId());
} }
@Test @Test
@@ -61,47 +61,42 @@ class EventPersistenceAdapterTest {
OffsetDateTime createdAt = OffsetDateTime createdAt =
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC); OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
var event = new Event( var event = new Event();
null, event.setEventToken(EventToken.generate());
EventToken.generate(), event.setOrganizerToken(OrganizerToken.generate());
OrganizerToken.generate(), event.setTitle("Full Event");
"Full Event", event.setDescription("A detailed description");
"A detailed description", event.setDateTime(dateTime);
dateTime, event.setTimezone(ZoneId.of("Europe/Berlin"));
ZoneId.of("Europe/Berlin"), event.setLocation("Berlin, Germany");
"Berlin, Germany", event.setExpiryDate(expiryDate);
expiryDate, event.setCreatedAt(createdAt);
createdAt,
false,
null);
Event saved = eventRepository.save(event); Event saved = eventRepository.save(event);
Event found = eventRepository.findByEventToken(saved.eventToken()).orElseThrow(); Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
assertThat(found.eventToken()).isEqualTo(event.eventToken()); assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
assertThat(found.organizerToken()).isEqualTo(event.organizerToken()); assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
assertThat(found.title()).isEqualTo("Full Event"); assertThat(found.getTitle()).isEqualTo("Full Event");
assertThat(found.description()).isEqualTo("A detailed description"); assertThat(found.getDescription()).isEqualTo("A detailed description");
assertThat(found.dateTime().toInstant()).isEqualTo(dateTime.toInstant()); assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
assertThat(found.timezone()).isEqualTo(ZoneId.of("Europe/Berlin")); assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
assertThat(found.location()).isEqualTo("Berlin, Germany"); assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
assertThat(found.expiryDate()).isEqualTo(expiryDate); assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
assertThat(found.createdAt().toInstant()).isEqualTo(createdAt.toInstant()); assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
} }
private Event buildEvent() { private Event buildEvent() {
return new Event( var event = new Event();
null, event.setEventToken(EventToken.generate());
EventToken.generate(), event.setOrganizerToken(OrganizerToken.generate());
OrganizerToken.generate(), event.setTitle("Test Event");
"Test Event", event.setDescription("Test description");
"Test description", event.setDateTime(OffsetDateTime.now().plusDays(7));
OffsetDateTime.now().plusDays(7), event.setTimezone(ZoneId.of("Europe/Berlin"));
ZoneId.of("Europe/Berlin"), event.setLocation("Somewhere");
"Somewhere", event.setExpiryDate(LocalDate.now().plusDays(30));
LocalDate.now().plusDays(30), event.setCreatedAt(OffsetDateTime.now());
OffsetDateTime.now(), return event;
false,
null);
} }
} }

View File

@@ -1,133 +0,0 @@
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());
}
}

View File

@@ -1,6 +1,7 @@
package de.fete.application.service; package de.fete.application.service;
import static org.assertj.core.api.Assertions.assertThat; 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.ArgumentMatchers.any;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -52,18 +53,19 @@ class EventServiceTest {
"Come celebrate!", "Come celebrate!",
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(), TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
ZONE, ZONE,
"Berlin" "Berlin",
TODAY.plusDays(120)
); );
Event result = eventService.createEvent(command); Event result = eventService.createEvent(command);
assertThat(result.title()).isEqualTo("Birthday Party"); assertThat(result.getTitle()).isEqualTo("Birthday Party");
assertThat(result.description()).isEqualTo("Come celebrate!"); assertThat(result.getDescription()).isEqualTo("Come celebrate!");
assertThat(result.timezone()).isEqualTo(ZONE); assertThat(result.getTimezone()).isEqualTo(ZONE);
assertThat(result.location()).isEqualTo("Berlin"); assertThat(result.getLocation()).isEqualTo("Berlin");
assertThat(result.eventToken()).isNotNull(); assertThat(result.getEventToken()).isNotNull();
assertThat(result.organizerToken()).isNotNull(); assertThat(result.getOrganizerToken()).isNotNull();
assertThat(result.createdAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE)); assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
} }
@Test @Test
@@ -73,30 +75,98 @@ class EventServiceTest {
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY.plusDays(11)
); );
eventService.createEvent(command); eventService.createEvent(command);
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class); ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
verify(eventRepository, times(1)).save(captor.capture()); verify(eventRepository, times(1)).save(captor.capture());
assertThat(captor.getValue().title()).isEqualTo("Test"); assertThat(captor.getValue().getTitle()).isEqualTo("Test");
} }
@Test @Test
void expiryDateIsEventDatePlusSevenDays() { 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() {
when(eventRepository.save(any(Event.class))) when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
var eventDate = TODAY.plusDays(10);
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
eventDate.atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
TODAY.plusDays(2)
); );
Event result = eventService.createEvent(command); Event result = eventService.createEvent(command);
assertThat(result.expiryDate()).isEqualTo(eventDate.plusDays(7)); 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));
} }
// --- GetEventUseCase tests (T004) --- // --- GetEventUseCase tests (T004) ---
@@ -104,15 +174,16 @@ class EventServiceTest {
@Test @Test
void getByEventTokenReturnsEvent() { void getByEventTokenReturnsEvent() {
EventToken token = EventToken.generate(); EventToken token = EventToken.generate();
var event = new Event(null, token, null, "Found Event", null, null, null, null, null, null, var event = new Event();
false, null); event.setEventToken(token);
event.setTitle("Found Event");
when(eventRepository.findByEventToken(token)) when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event)); .thenReturn(Optional.of(event));
Optional<Event> result = eventService.getByEventToken(token); Optional<Event> result = eventService.getByEventToken(token);
assertThat(result).isPresent(); assertThat(result).isPresent();
assertThat(result.get().title()).isEqualTo("Found Event"); assertThat(result.get().getTitle()).isEqualTo("Found Event");
} }
@Test @Test
@@ -136,11 +207,12 @@ class EventServiceTest {
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
ZoneId.of("America/New_York"), null ZoneId.of("America/New_York"), null,
TODAY.plusDays(11)
); );
Event result = eventService.createEvent(command); Event result = eventService.createEvent(command);
assertThat(result.timezone()).isEqualTo(ZoneId.of("America/New_York")); assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
} }
} }

View File

@@ -6,10 +6,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import de.fete.application.service.exception.EventCancelledException;
import de.fete.application.service.exception.EventExpiredException;
import de.fete.application.service.exception.EventNotFoundException;
import de.fete.application.service.exception.InvalidOrganizerTokenException;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken; import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken; import de.fete.domain.model.OrganizerToken;
@@ -55,23 +51,23 @@ class RsvpServiceTest {
@Test @Test
void createRsvpSucceedsForActiveEvent() { void createRsvpSucceedsForActiveEvent() {
Event event = buildActiveEvent(TODAY.plusDays(30)); Event event = buildActiveEvent();
EventToken token = event.eventToken(); EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class))) when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann"); Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
assertThat(result.name()).isEqualTo("Max Mustermann"); assertThat(result.getName()).isEqualTo("Max Mustermann");
assertThat(result.rsvpToken()).isNotNull(); assertThat(result.getRsvpToken()).isNotNull();
assertThat(result.eventId()).isEqualTo(event.id()); assertThat(result.getEventId()).isEqualTo(event.getId());
} }
@Test @Test
void createRsvpPersistsViaRepository() { void createRsvpPersistsViaRepository() {
Event event = buildActiveEvent(TODAY.plusDays(30)); Event event = buildActiveEvent();
EventToken token = event.eventToken(); EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class))) when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
@@ -80,8 +76,8 @@ class RsvpServiceTest {
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class); ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
verify(rsvpRepository).save(captor.capture()); verify(rsvpRepository).save(captor.capture());
assertThat(captor.getValue().name()).isEqualTo("Test Guest"); assertThat(captor.getValue().getName()).isEqualTo("Test Guest");
assertThat(captor.getValue().eventId()).isEqualTo(event.id()); assertThat(captor.getValue().getEventId()).isEqualTo(event.getId());
} }
@Test @Test
@@ -95,21 +91,22 @@ class RsvpServiceTest {
@Test @Test
void createRsvpTrimsName() { void createRsvpTrimsName() {
Event event = buildActiveEvent(TODAY.plusDays(30)); Event event = buildActiveEvent();
EventToken token = event.eventToken(); EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.save(any(Rsvp.class))) when(rsvpRepository.save(any(Rsvp.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
Rsvp result = rsvpService.createRsvp(token, " Max "); Rsvp result = rsvpService.createRsvp(token, " Max ");
assertThat(result.name()).isEqualTo("Max"); assertThat(result.getName()).isEqualTo("Max");
} }
@Test @Test
void createRsvpThrowsWhenEventExpired() { void createRsvpThrowsWhenEventExpired() {
Event event = buildActiveEvent(TODAY.minusDays(1)); var event = buildActiveEvent();
EventToken token = event.eventToken(); event.setExpiryDate(TODAY.minusDays(1));
EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest")) assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
@@ -118,8 +115,9 @@ class RsvpServiceTest {
@Test @Test
void createRsvpThrowsWhenEventExpiresToday() { void createRsvpThrowsWhenEventExpiresToday() {
Event event = buildActiveEvent(TODAY); var event = buildActiveEvent();
EventToken token = event.eventToken(); event.setExpiryDate(TODAY);
EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest")) assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
@@ -128,12 +126,12 @@ class RsvpServiceTest {
@Test @Test
void getAttendeeNamesReturnsNamesInOrder() { void getAttendeeNamesReturnsNamesInOrder() {
Event event = buildActiveEvent(TODAY.plusDays(30)); Event event = buildActiveEvent();
EventToken token = event.eventToken(); EventToken token = event.getEventToken();
OrganizerToken orgToken = event.organizerToken(); OrganizerToken orgToken = event.getOrganizerToken();
when(eventRepository.findByEventToken(token)) when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event)); .thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.id())) when(rsvpRepository.findByEventId(event.getId()))
.thenReturn(List.of( .thenReturn(List.of(
buildRsvp(1L, "Alice"), buildRsvp(1L, "Alice"),
buildRsvp(2L, "Bob"), buildRsvp(2L, "Bob"),
@@ -146,12 +144,12 @@ class RsvpServiceTest {
@Test @Test
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() { void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
Event event = buildActiveEvent(TODAY.plusDays(30)); Event event = buildActiveEvent();
EventToken token = event.eventToken(); EventToken token = event.getEventToken();
OrganizerToken orgToken = event.organizerToken(); OrganizerToken orgToken = event.getOrganizerToken();
when(eventRepository.findByEventToken(token)) when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event)); .thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.id())) when(rsvpRepository.findByEventId(event.getId()))
.thenReturn(List.of()); .thenReturn(List.of());
List<String> names = rsvpService.getAttendeeNames(token, orgToken); List<String> names = rsvpService.getAttendeeNames(token, orgToken);
@@ -173,8 +171,8 @@ class RsvpServiceTest {
@Test @Test
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() { void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
Event event = buildActiveEvent(TODAY.plusDays(30)); Event event = buildActiveEvent();
EventToken token = event.eventToken(); EventToken token = event.getEventToken();
OrganizerToken wrongToken = OrganizerToken.generate(); OrganizerToken wrongToken = OrganizerToken.generate();
when(eventRepository.findByEventToken(token)) when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event)); .thenReturn(Optional.of(event));
@@ -185,57 +183,24 @@ class RsvpServiceTest {
} }
private Rsvp buildRsvp(Long id, String name) { private Rsvp buildRsvp(Long id, String name) {
return new Rsvp(id, RsvpToken.generate(), 1L, name); var rsvp = new Rsvp();
rsvp.setId(id);
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(1L);
rsvp.setName(name);
return rsvp;
} }
@Test private Event buildActiveEvent() {
void cancelRsvpDeletesWhenEventAndRsvpExist() { var event = new Event();
Event event = buildActiveEvent(TODAY.plusDays(30)); event.setId(1L);
EventToken token = event.eventToken(); event.setEventToken(EventToken.generate());
RsvpToken rsvpToken = RsvpToken.generate(); event.setOrganizerToken(OrganizerToken.generate());
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); event.setTitle("Test Event");
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(true); event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
event.setTimezone(ZONE);
rsvpService.cancelRsvp(token, rsvpToken); event.setExpiryDate(TODAY.plusDays(30));
event.setCreatedAt(OffsetDateTime.now());
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken); return event;
}
@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);
} }
} }

View File

@@ -29,10 +29,8 @@ class WebConfigTest {
@Test @Test
void apiPrefixNotAccessibleWithoutIt() throws Exception { void apiPrefixNotAccessibleWithoutIt() throws Exception {
// /events without /api prefix should not resolve to the REST API endpoint; // /events without /api prefix should not resolve to the API endpoint
// it is served by SpaController as HTML instead mockMvc.perform(get("/events"))
mockMvc.perform(get("/events") .andExpect(status().isNotFound());
.accept("text/html"))
.andExpect(status().isOk());
} }
} }

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- OG_META_TAGS -->
<title>fete</title>
</head>
<body>
<div id="app"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

View File

@@ -1,210 +0,0 @@
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',
)
})
})

View File

@@ -1,166 +0,0 @@
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()
})
})

View File

@@ -1,276 +0,0 @@
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()
})
})

View File

@@ -1,74 +0,0 @@
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()
})
})

View File

@@ -9,6 +9,7 @@ test.describe('US-1: Create an event', () => {
await expect(page.getByText('Title is required.')).toBeVisible() await expect(page.getByText('Title is required.')).toBeVisible()
await expect(page.getByText('Date and time are 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 }) => { test('creates an event and redirects to event detail page', async ({ page }) => {
@@ -18,6 +19,7 @@ test.describe('US-1: Create an event', () => {
await page.getByLabel(/description/i).fill('Bring your own drinks') await page.getByLabel(/description/i).fill('Bring your own drinks')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00') await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
await page.getByLabel(/location/i).fill('Central Park') 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() await page.getByRole('button', { name: /create event/i }).click()
@@ -29,6 +31,7 @@ test.describe('US-1: Create an event', () => {
await page.getByLabel(/title/i).fill('Summer BBQ') await page.getByLabel(/title/i).fill('Summer BBQ')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00') 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 page.getByRole('button', { name: /create event/i }).click()
await expect(page).toHaveURL(/\/events\/.+/) await expect(page).toHaveURL(/\/events\/.+/)
@@ -56,6 +59,7 @@ test.describe('US-1: Create an event', () => {
await page.goto('/create') await page.goto('/create')
await page.getByLabel(/title/i).fill('Test') await page.getByLabel(/title/i).fill('Test')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00') 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 page.getByRole('button', { name: /create event/i }).click()

View File

@@ -9,6 +9,7 @@ const fullEvent = {
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
location: 'Central Park, NYC', location: 'Central Park, NYC',
attendeeCount: 12, attendeeCount: 12,
expired: false,
} }
test.describe('US1: RSVP submission flow', () => { test.describe('US1: RSVP submission flow', () => {
@@ -169,4 +170,16 @@ test.describe('US1: RSVP submission flow', () => {
await expect(page.getByText("You're attending!")).not.toBeVisible() 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()
})
}) })

View File

@@ -9,6 +9,7 @@ const fullEvent = {
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
location: 'Central Park, NYC', location: 'Central Park, NYC',
attendeeCount: 12, attendeeCount: 12,
expired: false,
} }
test.describe('US-1: View event details', () => { test.describe('US-1: View event details', () => {
@@ -51,6 +52,20 @@ 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.describe('US-4: Event not found', () => {
test('shows "event not found" for unknown token', async ({ page, network }) => { test('shows "event not found" for unknown token', async ({ page, network }) => {
network.use( network.use(

View File

@@ -7,6 +7,7 @@ const futureEvent1: StoredEvent = {
eventToken: 'future-aaa', eventToken: 'future-aaa',
title: 'Summer BBQ', title: 'Summer BBQ',
dateTime: '2027-06-15T18:00:00Z', dateTime: '2027-06-15T18:00:00Z',
expiryDate: '2027-06-16T00:00:00Z',
organizerToken: 'org-token-1', organizerToken: 'org-token-1',
} }
@@ -14,6 +15,7 @@ const futureEvent2: StoredEvent = {
eventToken: 'future-bbb', eventToken: 'future-bbb',
title: 'Team Meeting', title: 'Team Meeting',
dateTime: '2027-01-10T09:00:00Z', dateTime: '2027-01-10T09:00:00Z',
expiryDate: '2027-01-11T00:00:00Z',
rsvpToken: 'rsvp-token-1', rsvpToken: 'rsvp-token-1',
rsvpName: 'Alice', rsvpName: 'Alice',
} }
@@ -22,6 +24,7 @@ const pastEvent: StoredEvent = {
eventToken: 'past-ccc', eventToken: 'past-ccc',
title: 'New Year Party', title: 'New Year Party',
dateTime: '2025-01-01T00:00:00Z', dateTime: '2025-01-01T00:00:00Z',
expiryDate: '2025-01-02T00:00:00Z',
} }
function seedEvents(events: StoredEvent[]): string { function seedEvents(events: StoredEvent[]): string {
@@ -82,6 +85,7 @@ test.describe('US4: Past Events Appear Faded', () => {
location: '', location: '',
timezone: 'UTC', timezone: 'UTC',
attendeeCount: 0, attendeeCount: 0,
expired: true,
}) })
}), }),
) )
@@ -93,30 +97,19 @@ test.describe('US4: Past Events Appear Faded', () => {
}) })
test.describe('US3: Remove Event from List', () => { test.describe('US3: Remove Event from List', () => {
test('delete icon triggers confirmation dialog, confirm removes event', async ({ test('delete icon triggers confirmation dialog, confirm removes event', async ({ page }) => {
page,
network,
}) => {
await page.addInitScript(seedEvents([futureEvent1, futureEvent2])) 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('/') await page.goto('/')
// Both events visible // Both events visible
await expect(page.getByText('Summer BBQ')).toBeVisible() await expect(page.getByText('Summer BBQ')).toBeVisible()
await expect(page.getByText('Team Meeting')).toBeVisible() await expect(page.getByText('Team Meeting')).toBeVisible()
// Click delete on Summer BBQ (organizer event) // Click delete on Summer BBQ
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click() await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
// Confirmation dialog appears (organizer event shows "Cancel event?") // Confirmation dialog appears
await expect(page.getByText('Cancel event?')).toBeVisible() await expect(page.getByText('Remove event?')).toBeVisible()
// Confirm removal // Confirm removal
await page.getByRole('button', { name: 'Remove', exact: true }).click() await page.getByRole('button', { name: 'Remove', exact: true }).click()
@@ -131,13 +124,13 @@ test.describe('US3: Remove Event from List', () => {
await page.goto('/') await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click() await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByText('Cancel event?')).toBeVisible() await expect(page.getByText('Remove event?')).toBeVisible()
// Cancel // Cancel
await page.getByRole('button', { name: 'Cancel' }).click() await page.getByRole('button', { name: 'Cancel' }).click()
// Dialog gone, event still there // Dialog gone, event still there
await expect(page.getByText('Cancel event?')).not.toBeVisible() await expect(page.getByText('Remove event?')).not.toBeVisible()
await expect(page.getByText('Summer BBQ')).toBeVisible() await expect(page.getByText('Summer BBQ')).toBeVisible()
}) })
}) })
@@ -150,7 +143,7 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' }) const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
const badge = card.locator('.event-card__badge') const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible() await expect(badge).toBeVisible()
await expect(badge).toHaveText('Organizing') await expect(badge).toHaveText('Organizer')
await expect(badge).toHaveClass(/event-card__badge--organizer/) await expect(badge).toHaveClass(/event-card__badge--organizer/)
}) })
@@ -161,19 +154,16 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' }) const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
const badge = card.locator('.event-card__badge') const badge = card.locator('.event-card__badge')
await expect(badge).toBeVisible() await expect(badge).toBeVisible()
await expect(badge).toHaveText('Attending') await expect(badge).toHaveText('Attendee')
await expect(badge).toHaveClass(/event-card__badge--attendee/) await expect(badge).toHaveClass(/event-card__badge--attendee/)
}) })
test('shows watcher badge for events without organizerToken or rsvpToken', async ({ page }) => { test('shows no badge for events without organizerToken or rsvpToken', async ({ page }) => {
await page.addInitScript(seedEvents([pastEvent])) await page.addInitScript(seedEvents([pastEvent]))
await page.goto('/') await page.goto('/')
const card = page.locator('.event-card').filter({ hasText: 'New Year Party' }) const card = page.locator('.event-card').filter({ hasText: 'New Year Party' })
const badge = card.locator('.event-card__badge') await expect(card.locator('.event-card__badge')).toHaveCount(0)
await expect(badge).toBeVisible()
await expect(badge).toHaveText('Watching')
await expect(badge).toHaveClass(/event-card__badge--watcher/)
}) })
}) })
@@ -209,11 +199,13 @@ test.describe('Temporal Grouping: Section Headers', () => {
eventToken: 'today-1', eventToken: 'today-1',
title: 'Today Standup', title: 'Today Standup',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(), dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
expiryDate: '',
} }
const laterEvent: StoredEvent = { const laterEvent: StoredEvent = {
eventToken: 'later-1', eventToken: 'later-1',
title: 'Future Conference', title: 'Future Conference',
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(), dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
expiryDate: '',
} }
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent])) await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
await page.goto('/') await page.goto('/')
@@ -253,6 +245,7 @@ test.describe('Temporal Grouping: Section Headers', () => {
eventToken: 'today-emph', eventToken: 'today-emph',
title: 'Emphasis Test', title: 'Emphasis Test',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(), dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
expiryDate: '',
} }
await page.addInitScript(seedEvents([todayEvent])) await page.addInitScript(seedEvents([todayEvent]))
await page.goto('/') await page.goto('/')
@@ -269,6 +262,7 @@ test.describe('Temporal Grouping: Date Subheaders', () => {
eventToken: 'today-sub', eventToken: 'today-sub',
title: 'No Subheader Test', title: 'No Subheader Test',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(), dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
expiryDate: '',
} }
await page.addInitScript(seedEvents([todayEvent])) await page.addInitScript(seedEvents([todayEvent]))
await page.goto('/') await page.goto('/')
@@ -361,6 +355,7 @@ test.describe('US1: View My Events', () => {
location: '', location: '',
timezone: 'UTC', timezone: 'UTC',
attendeeCount: 0, attendeeCount: 0,
expired: false,
}) })
}), }),
) )

View File

@@ -1,108 +0,0 @@
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')
})
})

View File

@@ -1,218 +0,0 @@
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()
})
})

View File

@@ -3,8 +3,6 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- OG_META_TAGS -->
<title>fete</title> <title>fete</title>
</head> </head>
<body> <body>

File diff suppressed because it is too large Load Diff

View File

@@ -38,27 +38,21 @@
"@vue/tsconfig": "^0.9.0", "@vue/tsconfig": "^0.9.0",
"eslint": "^10.0.2", "eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.55.0", "eslint-plugin-oxlint": "~1.51.0",
"eslint-plugin-vue": "~10.8.0", "eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"msw": "^2.12.10", "msw": "^2.12.10",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"oxlint": "~1.55.0", "oxlint": "~1.51.0",
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.0", "vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.6", "vite-plugin-vue-devtools": "^8.0.6",
"vitest": "^4.0.18", "vitest": "^4.0.18",
"vue-tsc": "^3.2.5" "vue-tsc": "^3.2.5"
}, },
"browserslist": [
">= 0.5%",
"last 2 versions",
"Firefox ESR",
"not dead"
],
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y="0.9em" font-size="80" x="50%" text-anchor="middle">🎉</text>
</svg>

Before

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -1,35 +1,9 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<header v-if="route.name !== 'home'" class="app-header">
<BackLink />
<div id="header-actions"></div>
</header>
<RouterView /> <RouterView />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { RouterView, useRoute } from 'vue-router' import { RouterView } from 'vue-router'
import BackLink from '@/components/BackLink.vue'
const route = useRoute()
</script> </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>

View File

@@ -16,37 +16,6 @@
--color-text-on-gradient: #ffffff; --color-text-on-gradient: #ffffff;
--color-surface: #fff5f8; --color-surface: #fff5f8;
--color-card: #ffffff; --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);
--color-glass-subtle: rgba(255, 255, 255, 0.05);
--color-glass-border: rgba(255, 255, 255, 0.18);
--color-glass-border-hover: rgba(255, 255, 255, 0.3);
--color-glass-hover: rgba(255, 255, 255, 0.18);
--color-glass-inner: rgba(27, 23, 48, 0.55);
--color-glass-overlay: rgba(27, 23, 48, 0.4);
/* Text on gradient (opacity variants) */
--color-text-muted: rgba(255, 255, 255, 0.5);
--color-text-secondary: rgba(255, 255, 255, 0.7);
--color-text-soft: rgba(255, 255, 255, 0.85);
--color-text-bright: rgba(255, 255, 255, 0.9);
/* Glow border */
--gradient-glow: conic-gradient(from 135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
/* Gradient */ /* Gradient */
--gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%); --gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
@@ -64,7 +33,7 @@
--radius-button: 14px; --radius-button: 14px;
/* Shadows */ /* Shadows */
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.12); --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15); --shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
/* Layout */ /* Layout */
@@ -91,22 +60,7 @@ html {
body { body {
min-height: 100vh; min-height: 100vh;
background-color: var(--color-dark-base); background: var(--gradient-primary);
position: relative;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-color: var(--color-dark-base);
background-image:
radial-gradient(at 70% 20%, rgba(240, 98, 146, 0.55) 0px, transparent 50%),
radial-gradient(at 25% 50%, rgba(171, 71, 188, 0.5) 0px, transparent 55%),
radial-gradient(at 80% 70%, rgba(92, 107, 192, 0.55) 0px, transparent 50%),
radial-gradient(at 35% 85%, rgba(255, 112, 67, 0.3) 0px, transparent 40%);
filter: blur(80px);
z-index: -1;
} }
#app { #app {
@@ -128,66 +82,38 @@ body::before {
/* Card-style form fields */ /* Card-style form fields */
.form-field { .form-field {
background: var(--color-card); background: var(--color-card);
border: 1px solid #e0e0e0; border: none;
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-md); padding: var(--spacing-md) var(--spacing-md);
box-shadow: var(--shadow-card);
width: 100%; width: 100%;
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 400; font-weight: 400;
color: var(--color-text); color: var(--color-text);
outline: none; outline: none;
transition: border-color 0.2s ease; transition: box-shadow 0.2s ease;
}
.form-field.glass {
color: var(--color-text-on-gradient);
} }
.form-field:focus { .form-field:focus {
border-color: var(--color-glass-border-hover); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
} }
.form-field::placeholder { .form-field::placeholder {
color: var(--color-text-muted); color: #999;
font-weight: 400; font-weight: 400;
} }
.form-field.glass::placeholder {
color: var(--color-text-muted);
}
textarea.form-field { textarea.form-field {
resize: vertical; resize: vertical;
min-height: 5rem; 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 (label + input) */
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
overflow: hidden;
} }
.form-label { .form-label {
@@ -202,29 +128,22 @@ input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrap
display: block; display: block;
width: 100%; width: 100%;
padding: var(--spacing-md) var(--spacing-lg); padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-card); background: var(--color-accent);
color: var(--color-text); color: var(--color-text);
border: 1px solid #e0e0e0; border: none;
border-radius: var(--radius-button); border-radius: var(--radius-button);
font-family: inherit; font-family: inherit;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s ease, transform 0.1s ease; box-shadow: var(--shadow-button);
transition: opacity 0.2s ease, transform 0.1s ease;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
} }
.btn-primary.glass {
color: var(--color-text-on-gradient);
border: 2px solid transparent;
background:
linear-gradient(var(--color-glass-inner), var(--color-glass-inner)) padding-box,
var(--gradient-glow) border-box;
}
.btn-primary:hover { .btn-primary:hover {
border-color: var(--color-glass-border-hover); opacity: 0.92;
} }
.btn-primary:active { .btn-primary:active {
@@ -238,7 +157,7 @@ input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrap
/* Error message */ /* Error message */
.field-error { .field-error {
color: var(--color-danger-solid); color: #fff;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
padding-left: 0.25rem; padding-left: 0.25rem;
@@ -257,132 +176,6 @@ input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrap
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
} }
/* ── Glass System ── */
/* Glass surface: passive containers on gradient (cards, icon boxes) */
.glass {
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
box-shadow: var(--shadow-card);
backdrop-filter: blur(16px);
}
.glass:hover:not(input):not(textarea):not(.btn-primary) {
background: var(--color-glass-hover);
border-color: var(--color-glass-border-hover);
}
/* Glass interactive inner: dark translucent fill for interactive elements (FAB, CTA) */
.glass-inner {
background: var(--color-glass-inner);
backdrop-filter: blur(16px);
}
/* Glow border: conic gradient wrapper with halo (static) */
.glow-border {
background: var(--gradient-glow);
padding: 2px;
position: relative;
}
.glow-border::before {
content: '';
position: absolute;
inset: -4px;
border-radius: inherit;
background: var(--gradient-glow);
filter: blur(8px);
opacity: 0.3;
z-index: -1;
}
/* Glow border animated variant */
@property --glow-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.glow-border--animated {
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
animation: glow-rotate 4s linear infinite;
}
.glow-border--animated::before {
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
animation: glow-rotate 4s linear infinite;
}
@keyframes glow-rotate {
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 */ /* Utility */
.text-center { .text-center {
text-align: center; text-align: center;
@@ -404,7 +197,7 @@ input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrap
.sheet-title { .sheet-title {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-on-gradient); color: var(--color-text);
} }
.rsvp-form { .rsvp-form {
@@ -413,16 +206,15 @@ input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrap
gap: var(--spacing-md); gap: var(--spacing-md);
} }
.rsvp-form__label, .rsvp-form__label {
.cancel-form__label {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-on-gradient); color: var(--color-text);
padding-left: 0.25rem; padding-left: 0.25rem;
} }
.rsvp-form__field-error { .rsvp-form__field-error {
color: var(--color-danger-solid); color: #d32f2f;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
padding-left: 0.25rem; padding-left: 0.25rem;

View File

@@ -28,7 +28,7 @@ defineProps<{
.attendee-list__heading { .attendee-list__heading {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-muted); color: rgba(255, 255, 255, 0.5);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
@@ -44,7 +44,7 @@ defineProps<{
.attendee-list__item { .attendee-list__item {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text-soft); color: rgba(255, 255, 255, 0.85);
line-height: 1.4; line-height: 1.4;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -53,7 +53,7 @@ defineProps<{
.attendee-list__empty { .attendee-list__empty {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--color-text-muted); color: rgba(255, 255, 255, 0.5);
font-style: italic; font-style: italic;
} }
</style> </style>

View File

@@ -1,28 +0,0 @@
<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>

View File

@@ -2,18 +2,7 @@
<Teleport to="body"> <Teleport to="body">
<Transition name="sheet"> <Transition name="sheet">
<div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')"> <div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')">
<div <div class="sheet" role="dialog" aria-modal="true" :aria-label="label" ref="sheetEl" tabindex="-1">
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" /> <div class="sheet__handle" aria-hidden="true" />
<slot /> <slot />
</div> </div>
@@ -23,14 +12,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue' import { ref, watch, nextTick } from 'vue'
defineProps<{ defineProps<{
open: boolean open: boolean
label: string label: string
}>() }>()
const emit = defineEmits<{ defineEmits<{
close: [] close: []
}>() }>()
@@ -50,52 +39,13 @@ 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> </script>
<style scoped> <style scoped>
.sheet-backdrop { .sheet-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: var(--color-glass-overlay); background: rgba(0, 0, 0, 0.4);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
@@ -103,10 +53,7 @@ function onTouchEnd() {
} }
.sheet { .sheet {
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%); background: var(--color-card);
border: 1px solid var(--color-glass-border);
border-bottom: none;
backdrop-filter: blur(24px);
border-radius: 20px 20px 0 0; border-radius: 20px 20px 0 0;
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl); padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
width: 100%; width: 100%;
@@ -120,7 +67,7 @@ function onTouchEnd() {
.sheet__handle { .sheet__handle {
width: 36px; width: 36px;
height: 4px; height: 4px;
background: var(--color-glass-border-hover); background: #ccc;
border-radius: 2px; border-radius: 2px;
align-self: center; align-self: center;
flex-shrink: 0; flex-shrink: 0;

View File

@@ -75,7 +75,7 @@ watch(
.confirm-dialog__overlay { .confirm-dialog__overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: var(--color-glass-overlay); background: rgba(0, 0, 0, 0.4);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -84,11 +84,9 @@ watch(
} }
.confirm-dialog { .confirm-dialog {
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%); background: var(--color-card);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(24px);
border-radius: var(--radius-card); border-radius: var(--radius-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
padding: var(--spacing-xl); padding: var(--spacing-xl);
max-width: 320px; max-width: 320px;
width: 100%; width: 100%;
@@ -100,13 +98,13 @@ watch(
.confirm-dialog__title { .confirm-dialog__title {
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-on-gradient); color: var(--color-text);
} }
.confirm-dialog__message { .confirm-dialog__message {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 400; font-weight: 400;
color: var(--color-text-soft); color: #666;
} }
.confirm-dialog__actions { .confirm-dialog__actions {
@@ -132,14 +130,13 @@ watch(
} }
.confirm-dialog__btn--cancel { .confirm-dialog__btn--cancel {
background: var(--color-glass); background: #e8e8e8;
border: 1px solid var(--color-glass-border); color: #555;
color: var(--color-text-on-gradient);
} }
.confirm-dialog__btn--confirm { .confirm-dialog__btn--confirm {
background: var(--color-danger-solid); background: #d32f2f;
color: var(--color-danger-solid-text); color: #fff;
} }
.confirm-dialog-enter-active, .confirm-dialog-enter-active,

View File

@@ -1,8 +1,6 @@
<template> <template>
<RouterLink to="/create" class="fab glow-border" aria-label="Create event"> <RouterLink to="/create" class="fab" aria-label="Create event">
<span class="fab__inner glass-inner"> <span class="fab__icon" aria-hidden="true">+</span>
<span class="fab__icon" aria-hidden="true">+</span>
</span>
</RouterLink> </RouterLink>
</template> </template>
@@ -18,26 +16,20 @@ import { RouterLink } from 'vue-router'
width: 56px; width: 56px;
height: 56px; height: 56px;
border-radius: 50%; border-radius: 50%;
color: var(--color-text-on-gradient); background: var(--color-accent);
color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
text-decoration: none; text-decoration: none;
z-index: 100; z-index: 100;
transition: transform 0.15s ease; transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.fab__inner {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
} }
.fab:hover { .fab:hover {
transform: scale(1.08); transform: scale(1.08);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
} }
.fab:active { .fab:active {
@@ -49,7 +41,6 @@ import { RouterLink } from 'vue-router'
outline-offset: 3px; outline-offset: 3px;
} }
.fab__icon { .fab__icon {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 300; font-weight: 300;

View File

@@ -12,7 +12,7 @@ defineProps<{
.date-subheader { .date-subheader {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
color: var(--color-text-soft); color: rgba(255, 255, 255, 0.85);
margin: 0; margin: 0;
padding: var(--spacing-xs) 0; padding: var(--spacing-xs) 0;
} }

View File

@@ -1,9 +1,7 @@
<template> <template>
<div class="empty-state"> <div class="empty-state">
<p class="empty-state__message">No events yet.<br />Create your first one!</p> <p class="empty-state__message">No events yet.<br />Create your first one!</p>
<RouterLink to="/create" class="empty-state__cta glow-border glow-border--animated"> <RouterLink to="/create" class="btn-primary empty-state__cta">+ Create Event</RouterLink>
<span class="empty-state__cta-inner glass-inner">Create Event</span>
</RouterLink>
</div> </div>
</template> </template>
@@ -29,34 +27,5 @@ import { RouterLink } from 'vue-router'
.empty-state__cta { .empty-state__cta {
max-width: 280px; max-width: 280px;
width: 100%;
border-radius: var(--radius-button);
text-decoration: none;
transition: transform 0.1s ease;
}
.empty-state__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;
}
.empty-state__cta:hover {
transform: scale(1.02);
}
.empty-state__cta:active {
transform: scale(0.98);
}
.empty-state__cta:focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
} }
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="event-card glass" class="event-card"
:class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }" :class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }"
:style="swipeStyle" :style="swipeStyle"
@touchstart="onTouchStart" @touchstart="onTouchStart"
@@ -12,7 +12,7 @@
<span class="event-card__time">{{ displayTime }}</span> <span class="event-card__time">{{ displayTime }}</span>
</RouterLink> </RouterLink>
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`"> <span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
{{ eventRole === 'organizer' ? 'Organizing' : eventRole === 'attendee' ? 'Attending' : 'Watching' }} {{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }}
</span> </span>
<button <button
class="event-card__delete" class="event-card__delete"
@@ -34,7 +34,7 @@ const props = defineProps<{
title: string title: string
relativeTime: string relativeTime: string
isPast: boolean isPast: boolean
eventRole?: 'organizer' | 'attendee' | 'watcher' eventRole?: 'organizer' | 'attendee'
timeDisplayMode?: 'clock' | 'relative' timeDisplayMode?: 'clock' | 'relative'
dateTime?: string dateTime?: string
}>() }>()
@@ -93,10 +93,11 @@ function onTouchEnd() {
.event-card { .event-card {
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--color-card);
border-radius: var(--radius-card); border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: var(--spacing-md) var(--spacing-lg); padding: var(--spacing-md) var(--spacing-lg);
gap: var(--spacing-sm); gap: var(--spacing-sm);
transition: background 0.2s ease, border-color 0.2s ease;
} }
.event-card--past { .event-card--past {
@@ -121,7 +122,7 @@ function onTouchEnd() {
.event-card__title { .event-card__title {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: var(--color-text-on-gradient); color: var(--color-text);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -130,7 +131,7 @@ function onTouchEnd() {
.event-card__time { .event-card__time {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 400; font-weight: 400;
color: var(--color-text-secondary); color: #888;
} }
.event-card__badge { .event-card__badge {
@@ -144,18 +145,12 @@ function onTouchEnd() {
.event-card__badge--organizer { .event-card__badge--organizer {
background: var(--color-accent); background: var(--color-accent);
color: var(--color-text-on-gradient); color: #fff;
} }
.event-card__badge--attendee { .event-card__badge--attendee {
background: var(--color-glass-strong); background: #e0e0e0;
color: var(--color-text-bright); color: #555;
}
.event-card__badge--watcher {
background: var(--color-glass);
color: var(--color-text-secondary);
border: 1px solid var(--color-glass-border);
} }
.event-card__delete { .event-card__delete {
@@ -168,14 +163,14 @@ function onTouchEnd() {
background: none; background: none;
border: none; border: none;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--color-text-muted); color: #bbb;
cursor: pointer; cursor: pointer;
border-radius: 50%; border-radius: 50%;
transition: color 0.15s ease, background 0.15s ease; transition: color 0.15s ease, background 0.15s ease;
} }
.event-card__delete:hover { .event-card__delete:hover {
color: var(--color-danger-solid); color: #d32f2f;
background: rgba(211, 47, 47, 0.08); background: rgba(211, 47, 47, 0.08);
} }

View File

@@ -27,8 +27,8 @@
</section> </section>
<ConfirmDialog <ConfirmDialog
:open="!!pendingDeleteToken" :open="!!pendingDeleteToken"
:title="deleteDialogTitle" title="Remove event?"
:message="deleteDialogMessage" message="This event will be removed from your list."
confirm-label="Remove" confirm-label="Remove"
cancel-label="Cancel" cancel-label="Cancel"
@confirm="confirmDelete" @confirm="confirmDelete"
@@ -42,106 +42,24 @@ import { computed, ref } from 'vue'
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage' import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
import { useEventGrouping } from '../composables/useEventGrouping' import { useEventGrouping } from '../composables/useEventGrouping'
import { formatRelativeTime } from '../composables/useRelativeTime' import { formatRelativeTime } from '../composables/useRelativeTime'
import { api } from '../api/client'
import EventCard from './EventCard.vue' import EventCard from './EventCard.vue'
import SectionHeader from './SectionHeader.vue' import SectionHeader from './SectionHeader.vue'
import DateSubheader from './DateSubheader.vue' import DateSubheader from './DateSubheader.vue'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
import type { StoredEvent } from '../composables/useEventStorage' import type { StoredEvent } from '../composables/useEventStorage'
const { getStoredEvents, getRsvp, getOrganizerToken, removeEvent } = useEventStorage() const { getStoredEvents, removeEvent } = useEventStorage()
const pendingDeleteToken = ref<string | null>(null) 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) { function requestDelete(eventToken: string) {
deleteError.value = ''
const role = getRole(getStoredEvents().find((e) => e.eventToken === eventToken)!)
if (role === 'watcher') {
removeEvent(eventToken)
return
}
pendingDeleteToken.value = eventToken pendingDeleteToken.value = eventToken
} }
async function confirmDelete() { function confirmDelete() {
if (!pendingDeleteToken.value) return if (pendingDeleteToken.value) {
removeEvent(pendingDeleteToken.value)
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 pendingDeleteToken.value = null
} }
@@ -149,10 +67,10 @@ function cancelDelete() {
pendingDeleteToken.value = null pendingDeleteToken.value = null
} }
function getRole(event: StoredEvent): 'organizer' | 'attendee' | 'watcher' { function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
if (event.organizerToken) return 'organizer' if (event.organizerToken) return 'organizer'
if (event.rsvpToken) return 'attendee' if (event.rsvpToken) return 'attendee'
return 'watcher' return undefined
} }
const groupedSections = computed(() => { const groupedSections = computed(() => {

View File

@@ -2,111 +2,27 @@
<div class="rsvp-bar"> <div class="rsvp-bar">
<div class="rsvp-bar__inner"> <div class="rsvp-bar__inner">
<!-- Status state: already RSVPed --> <!-- Status state: already RSVPed -->
<div v-if="hasRsvp" class="rsvp-bar__status-wrapper"> <div v-if="hasRsvp" class="rsvp-bar__status">
<div class="rsvp-bar__status-row"> <span class="rsvp-bar__check" aria-hidden="true"></span>
<div <span class="rsvp-bar__text">You're attending!</span>
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> </div>
<!-- CTA state: no RSVP yet --> <!-- CTA state: no RSVP yet -->
<div v-else class="rsvp-bar__row"> <button v-else class="btn-primary rsvp-bar__cta" type="button" @click="$emit('open')">
<div class="bar-icon glow-border glow-border--animated"> I'm attending
<button </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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' defineProps<{
const props = defineProps<{
hasRsvp?: boolean hasRsvp?: boolean
bookmarked?: boolean
}>() }>()
defineEmits<{ defineEmits<{
open: [] 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> </script>
<style scoped> <style scoped>
@@ -127,46 +43,22 @@ watch(expanded, (isExpanded) => {
max-width: var(--content-max-width); max-width: var(--content-max-width);
} }
.rsvp-bar__row { .rsvp-bar__cta {
display: flex; width: 100%;
gap: var(--spacing-sm);
}
.rsvp-bar__status-wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.rsvp-bar__status-row {
display: flex;
gap: var(--spacing-sm);
} }
.rsvp-bar__status { .rsvp-bar__status {
flex: 1;
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--spacing-xs); gap: var(--spacing-xs);
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%); background: var(--color-card);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(16px);
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-lg); padding: var(--spacing-md) var(--spacing-lg);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text-on-gradient); color: var(--color-text);
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 { .rsvp-bar__check {
@@ -180,81 +72,4 @@ watch(expanded, (isExpanded) => {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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> </style>

View File

@@ -15,7 +15,7 @@ defineProps<{
.section-header { .section-header {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-on-gradient); color: var(--color-text);
margin: 0; margin: 0;
padding: var(--spacing-sm) 0; padding: var(--spacing-sm) 0;
} }

View File

@@ -55,18 +55,12 @@ describe('EventCard', () => {
it('renders organizer badge when eventRole is organizer', () => { it('renders organizer badge when eventRole is organizer', () => {
const wrapper = mountCard({ eventRole: 'organizer' }) const wrapper = mountCard({ eventRole: 'organizer' })
expect(wrapper.text()).toContain('Organizing') expect(wrapper.text()).toContain('Organizer')
}) })
it('renders attendee badge when eventRole is attendee', () => { it('renders attendee badge when eventRole is attendee', () => {
const wrapper = mountCard({ eventRole: 'attendee' }) const wrapper = mountCard({ eventRole: 'attendee' })
expect(wrapper.text()).toContain('Attending') expect(wrapper.text()).toContain('Attendee')
})
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', () => { it('renders no badge when eventRole is undefined', () => {

View File

@@ -1,15 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import EventList from '../EventList.vue' import EventList from '../EventList.vue'
vi.mock('../../api/client', () => ({
api: {
PATCH: vi.fn(),
DELETE: vi.fn(),
},
}))
const router = createRouter({ const router = createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [
@@ -22,17 +15,13 @@ const router = createRouter({
const NOW = new Date(2026, 2, 11, 12, 0, 0) const NOW = new Date(2026, 2, 11, 12, 0, 0)
const mockEvents = [ const mockEvents = [
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00' }, { 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' }, { 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' }, { 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' }, { 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' }, { eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', expiryDate: '' },
{ 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', () => ({ vi.mock('../../composables/useEventStorage', () => ({
isValidStoredEvent: (e: unknown) => { isValidStoredEvent: (e: unknown) => {
if (typeof e !== 'object' || e === null) return false if (typeof e !== 'object' || e === null) return false
@@ -43,21 +32,7 @@ vi.mock('../../composables/useEventStorage', () => ({
}, },
useEventStorage: () => ({ useEventStorage: () => ({
getStoredEvents: () => mockEvents, getStoredEvents: () => mockEvents,
getRsvp: (token: string) => { removeEvent: vi.fn(),
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,
}), }),
})) }))
@@ -65,9 +40,7 @@ vi.mock('../../composables/useRelativeTime', () => ({
formatRelativeTime: (dateTime: string) => { formatRelativeTime: (dateTime: string) => {
if (dateTime.includes('03-01')) return '10 days ago' if (dateTime.includes('03-01')) return '10 days ago'
if (dateTime.includes('06-15')) return 'in 1 year' if (dateTime.includes('06-15')) return 'in 1 year'
if (dateTime.includes('03-11T18')) return 'in 6 hours' if (dateTime.includes('03-11')) 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-13')) return 'in 2 days'
if (dateTime.includes('03-16')) return 'in 5 days' if (dateTime.includes('03-16')) return 'in 5 days'
return 'sometime' return 'sometime'
@@ -76,10 +49,7 @@ vi.mock('../../composables/useRelativeTime', () => ({
function mountList() { function mountList() {
return mount(EventList, { return mount(EventList, {
global: { global: { plugins: [router] },
plugins: [router],
stubs: { Teleport: true },
},
}) })
} }
@@ -119,7 +89,7 @@ describe('EventList', () => {
it('renders all valid events as cards', () => { it('renders all valid events as cards', () => {
const wrapper = mountList() const wrapper = mountList()
const cards = wrapper.findAll('.event-card') const cards = wrapper.findAll('.event-card')
expect(cards).toHaveLength(7) expect(cards).toHaveLength(5)
}) })
it('marks past events with isPast class', () => { it('marks past events with isPast class', () => {
@@ -167,140 +137,4 @@ describe('EventList', () => {
const pastSection = wrapper.findAll('.event-section')[4]! const pastSection = wrapper.findAll('.event-section')[4]!
expect(pastSection.find('.date-subheader').exists()).toBe(true) 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')
})
}) })

View File

@@ -5,8 +5,8 @@ import RsvpBar from '../RsvpBar.vue'
describe('RsvpBar', () => { describe('RsvpBar', () => {
it('renders CTA button when hasRsvp is false', () => { it('renders CTA button when hasRsvp is false', () => {
const wrapper = mount(RsvpBar) const wrapper = mount(RsvpBar)
expect(wrapper.find('.bar-cta').exists()).toBe(true) expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.bar-cta-btn').text()).toBe("I'm attending!") expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false) expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
}) })
@@ -14,17 +14,17 @@ describe('RsvpBar', () => {
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } }) const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(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__text').text()).toBe("You're attending!")
expect(wrapper.find('.bar-cta').exists()).toBe(false) expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
}) })
it('emits open when CTA button is clicked', async () => { it('emits open when CTA button is clicked', async () => {
const wrapper = mount(RsvpBar) const wrapper = mount(RsvpBar)
await wrapper.find('.bar-cta-btn').trigger('click') await wrapper.find('.rsvp-bar__cta').trigger('click')
expect(wrapper.emitted('open')).toHaveLength(1) expect(wrapper.emitted('open')).toHaveLength(1)
}) })
it('does not render CTA button when hasRsvp is true', () => { it('does not render CTA button when hasRsvp is true', () => {
const wrapper = mount(RsvpBar, { props: { hasRsvp: true } }) const wrapper = mount(RsvpBar, { props: { hasRsvp: true } })
expect(wrapper.find('.bar-cta-btn').exists()).toBe(false) expect(wrapper.find('button').exists()).toBe(false)
}) })
}) })

View File

@@ -6,6 +6,7 @@ function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): Stor
return { return {
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`, eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
title: 'Test Event', title: 'Test Event',
expiryDate: '',
...overrides, ...overrides,
} }
} }

View File

@@ -43,6 +43,7 @@ describe('useEventStorage', () => {
organizerToken: 'org-456', organizerToken: 'org-456',
title: 'Birthday', title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
const events = getStoredEvents() const events = getStoredEvents()
@@ -60,6 +61,7 @@ describe('useEventStorage', () => {
organizerToken: 'org-456', organizerToken: 'org-456',
title: 'Test', title: 'Test',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
expect(getOrganizerToken('abc-123')).toBe('org-456') expect(getOrganizerToken('abc-123')).toBe('org-456')
@@ -77,12 +79,14 @@ describe('useEventStorage', () => {
eventToken: 'event-1', eventToken: 'event-1',
title: 'First', title: 'First',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
saveCreatedEvent({ saveCreatedEvent({
eventToken: 'event-2', eventToken: 'event-2',
title: 'Second', title: 'Second',
dateTime: '2026-07-15T20:00:00+02:00', dateTime: '2026-07-15T20:00:00+02:00',
expiryDate: '2026-08-15',
}) })
const events = getStoredEvents() const events = getStoredEvents()
@@ -98,12 +102,14 @@ describe('useEventStorage', () => {
eventToken: 'abc-123', eventToken: 'abc-123',
title: 'Old Title', title: 'Old Title',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
saveCreatedEvent({ saveCreatedEvent({
eventToken: 'abc-123', eventToken: 'abc-123',
title: 'New Title', title: 'New Title',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
const events = getStoredEvents() const events = getStoredEvents()
@@ -118,6 +124,7 @@ describe('useEventStorage', () => {
eventToken: 'abc-123', eventToken: 'abc-123',
title: 'Birthday', title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00', 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') saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00')
@@ -147,6 +154,7 @@ describe('useEventStorage', () => {
eventToken: 'abc-123', eventToken: 'abc-123',
title: 'Test', title: 'Test',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
expect(getRsvp('abc-123')).toBeUndefined() expect(getRsvp('abc-123')).toBeUndefined()
@@ -164,12 +172,14 @@ describe('useEventStorage', () => {
eventToken: 'event-1', eventToken: 'event-1',
title: 'First', title: 'First',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
saveCreatedEvent({ saveCreatedEvent({
eventToken: 'event-2', eventToken: 'event-2',
title: 'Second', title: 'Second',
dateTime: '2026-07-15T20:00:00+02:00', dateTime: '2026-07-15T20:00:00+02:00',
expiryDate: '2026-08-15',
}) })
removeEvent('event-1') removeEvent('event-1')
@@ -186,6 +196,7 @@ describe('useEventStorage', () => {
eventToken: 'event-1', eventToken: 'event-1',
title: 'First', title: 'First',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}) })
removeEvent('nonexistent') removeEvent('nonexistent')
@@ -194,71 +205,6 @@ 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', () => { describe('isValidStoredEvent', () => {
// Import directly since it's an exported function // Import directly since it's an exported function
let isValidStoredEvent: (e: unknown) => boolean let isValidStoredEvent: (e: unknown) => boolean
@@ -274,7 +220,8 @@ describe('isValidStoredEvent', () => {
eventToken: 'abc-123', eventToken: 'abc-123',
title: 'Birthday', title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
}), expiryDate: '2026-07-15',
}),
).toBe(true) ).toBe(true)
}) })

View File

@@ -1,120 +0,0 @@
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')
}
})
})

View File

@@ -3,6 +3,7 @@ export interface StoredEvent {
organizerToken?: string organizerToken?: string
title: string title: string
dateTime: string dateTime: string
expiryDate: string
rsvpToken?: string rsvpToken?: string
rsvpName?: string rsvpName?: string
} }
@@ -65,7 +66,7 @@ export function useEventStorage() {
existing.rsvpToken = rsvpToken existing.rsvpToken = rsvpToken
existing.rsvpName = rsvpName existing.rsvpName = rsvpName
} else { } else {
events.push({ eventToken, title, dateTime, rsvpToken, rsvpName }) events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName })
} }
writeEvents(events) writeEvents(events)
} }
@@ -78,34 +79,10 @@ export function useEventStorage() {
return undefined 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 { function removeEvent(eventToken: string): void {
const events = readEvents().filter((e) => e.eventToken !== eventToken) const events = readEvents().filter((e) => e.eventToken !== eventToken)
writeEvents(events) writeEvents(events)
} }
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, saveWatch, isStored, removeEvent } return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeEvent }
} }

View File

@@ -1,71 +0,0 @@
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 }
}

View File

@@ -1,69 +0,0 @@
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')
})
})

View File

@@ -1,28 +0,0 @@
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)
)
}

View File

@@ -1,6 +1,9 @@
<template> <template>
<main class="create"> <main class="create">
<h1 class="create__title">Great, a Party!</h1> <header class="create__header">
<RouterLink to="/" class="create__back" aria-label="Back to home">&larr;</RouterLink>
<h1 class="create__title">Create</h1>
</header>
<form class="create__form" novalidate @submit.prevent="handleSubmit"> <form class="create__form" novalidate @submit.prevent="handleSubmit">
<div class="form-group"> <div class="form-group">
@@ -9,7 +12,7 @@
id="title" id="title"
v-model="form.title" v-model="form.title"
type="text" type="text"
class="form-field glass" class="form-field"
required required
maxlength="200" maxlength="200"
placeholder="What's the event?" placeholder="What's the event?"
@@ -24,7 +27,7 @@
<textarea <textarea
id="description" id="description"
v-model="form.description" v-model="form.description"
class="form-field glass" class="form-field"
maxlength="2000" maxlength="2000"
placeholder="Tell people more about it…" placeholder="Tell people more about it…"
:aria-invalid="!!errors.description" :aria-invalid="!!errors.description"
@@ -39,7 +42,7 @@
id="dateTime" id="dateTime"
v-model="form.dateTime" v-model="form.dateTime"
type="datetime-local" type="datetime-local"
class="form-field glass" class="form-field"
required required
:aria-invalid="!!errors.dateTime" :aria-invalid="!!errors.dateTime"
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined" :aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
@@ -53,7 +56,7 @@
id="location" id="location"
v-model="form.location" v-model="form.location"
type="text" type="text"
class="form-field glass" class="form-field"
maxlength="500" maxlength="500"
placeholder="Where is it?" placeholder="Where is it?"
:aria-invalid="!!errors.location" :aria-invalid="!!errors.location"
@@ -62,7 +65,22 @@
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span> <span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
</div> </div>
<button type="submit" class="btn-primary glass" :disabled="submitting"> <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"
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" :disabled="submitting">
{{ submitting ? 'Creating…' : 'Create Event' }} {{ submitting ? 'Creating…' : 'Create Event' }}
</button> </button>
@@ -72,8 +90,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from 'vue' import { reactive, ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { api } from '@/api/client' import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage' import { useEventStorage } from '@/composables/useEventStorage'
@@ -85,6 +103,7 @@ const form = reactive({
description: '', description: '',
dateTime: '', dateTime: '',
location: '', location: '',
expiryDate: '',
}) })
const errors = reactive({ const errors = reactive({
@@ -92,22 +111,31 @@ const errors = reactive({
description: '', description: '',
dateTime: '', dateTime: '',
location: '', location: '',
expiryDate: '',
}) })
const submitting = ref(false) const submitting = ref(false)
const serverError = ref('') const serverError = ref('')
const tomorrow = computed(() => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().split('T')[0]
})
function clearErrors() { function clearErrors() {
errors.title = '' errors.title = ''
errors.description = '' errors.description = ''
errors.dateTime = '' errors.dateTime = ''
errors.location = '' errors.location = ''
errors.expiryDate = ''
serverError.value = '' serverError.value = ''
} }
// Clear individual field errors when the user types // Clear individual field errors when the user types
watch(() => form.title, () => { errors.title = ''; serverError.value = '' }) watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' }) watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
watch(() => form.description, () => { serverError.value = '' }) watch(() => form.description, () => { serverError.value = '' })
watch(() => form.location, () => { serverError.value = '' }) watch(() => form.location, () => { serverError.value = '' })
@@ -125,6 +153,14 @@ function validate(): boolean {
valid = false 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 return valid
} }
@@ -150,6 +186,7 @@ async function handleSubmit() {
dateTime: dateTimeWithOffset, dateTime: dateTimeWithOffset,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: form.location.trim() || undefined, location: form.location.trim() || undefined,
expiryDate: form.expiryDate,
}, },
}) })
@@ -175,6 +212,7 @@ async function handleSubmit() {
organizerToken: data.organizerToken, organizerToken: data.organizerToken,
title: data.title, title: data.title,
dateTime: data.dateTime, dateTime: data.dateTime,
expiryDate: data.expiryDate,
}) })
router.push({ name: 'event', params: { eventToken: data.eventToken } }) router.push({ name: 'event', params: { eventToken: data.eventToken } })
@@ -191,7 +229,20 @@ async function handleSubmit() {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-lg); gap: var(--spacing-lg);
padding-top: calc(var(--spacing-lg) + 2.5rem); 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;
} }
.create__title { .create__title {

View File

@@ -8,35 +8,12 @@
alt="" alt=""
/> />
<div class="detail__hero-overlay" /> <div class="detail__hero-overlay" />
<header class="detail__header">
<RouterLink to="/" class="detail__back" aria-label="Back to home">&larr;</RouterLink>
<span class="detail__brand">fete</span>
</header>
</div> </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"> <div class="detail__body">
<!-- Loading state --> <!-- Loading state -->
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details"> <div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
@@ -48,10 +25,8 @@
<!-- Loaded state --> <!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__content"> <div v-else-if="state === 'loaded' && event" class="detail__content">
<!-- Cancellation banner --> <div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
<div v-if="event.cancelled" class="detail__cancelled-banner" role="alert"> This event has ended.
<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> </div>
<h1 class="detail__title">{{ event.title }}</h1> <h1 class="detail__title">{{ event.title }}</h1>
@@ -95,84 +70,15 @@
<!-- Error state --> <!-- Error state -->
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert"> <div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
<p class="detail__message">Something went wrong.</p> <p class="detail__message">Something went wrong.</p>
<button class="btn-primary glass" type="button" @click="fetchEvent">Retry</button> <button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
</div> </div>
</div> </div>
<!-- Organizer bottom bar (not cancelled) --> <!-- RSVP bar (only for loaded, non-expired events) -->
<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 <RsvpBar
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled" v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
:has-rsvp="!!rsvpName" :has-rsvp="!!rsvpName"
:bookmarked="eventIsStored"
@open="sheetOpen = true" @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 --> <!-- RSVP bottom sheet -->
@@ -184,7 +90,7 @@
<input <input
id="rsvp-name" id="rsvp-name"
v-model.trim="nameInput" v-model.trim="nameInput"
class="form-field glass" class="form-field"
type="text" type="text"
placeholder="e.g. Max Mustermann" placeholder="e.g. Max Mustermann"
maxlength="100" maxlength="100"
@@ -193,11 +99,9 @@
/> />
<span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span> <span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span>
</div> </div>
<div class="rsvp-form__submit glow-border glow-border--animated"> <button class="btn-primary" type="submit" :disabled="submitting">
<button class="rsvp-form__submit-inner glass-inner" type="submit" :disabled="submitting"> {{ submitting ? 'Sending…' : "Count me in" }}
{{ submitting ? 'Sending…' : "Count me in" }} </button>
</button>
</div>
<p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p> <p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p>
</form> </form>
</BottomSheet> </BottomSheet>
@@ -205,14 +109,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { api } from '@/api/client' import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage' import { useEventStorage } from '@/composables/useEventStorage'
import { useIcalDownload } from '@/composables/useIcalDownload'
import AttendeeList from '@/components/AttendeeList.vue' import AttendeeList from '@/components/AttendeeList.vue'
import BottomSheet from '@/components/BottomSheet.vue' import BottomSheet from '@/components/BottomSheet.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import RsvpBar from '@/components/RsvpBar.vue' import RsvpBar from '@/components/RsvpBar.vue'
import type { components } from '@/api/schema' import type { components } from '@/api/schema'
@@ -220,8 +122,7 @@ type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error' type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute() const route = useRoute()
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage() const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage()
const { download: downloadIcal } = useIcalDownload()
const state = ref<State>('loading') const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null) const event = ref<GetEventResponse | null>(null)
@@ -233,60 +134,9 @@ const nameError = ref('')
const submitError = ref('') const submitError = ref('')
const submitting = ref(false) const submitting = ref(false)
const rsvpName = ref<string | undefined>(undefined) const rsvpName = ref<string | undefined>(undefined)
const confirmCancelOpen = ref(false)
const cancelError = ref('')
const isOrganizer = ref(false) const isOrganizer = ref(false)
const attendeeNames = ref<string[] | null>(null) 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(() => { const formattedDateTime = computed(() => {
if (!event.value) return '' if (!event.value) return ''
const formatted = new Intl.DateTimeFormat(undefined, { const formatted = new Intl.DateTimeFormat(undefined, {
@@ -301,8 +151,8 @@ async function fetchEvent() {
event.value = null event.value = null
try { try {
const { data, error, response } = await api.GET('/events/{eventToken}', { const { data, error, response } = await api.GET('/events/{token}', {
params: { path: { eventToken: route.params.eventToken as string } }, params: { path: { token: route.params.eventToken as string } },
}) })
if (error) { if (error) {
@@ -349,8 +199,8 @@ async function submitRsvp() {
submitting.value = true submitting.value = true
try { try {
const { data, error } = await api.POST('/events/{eventToken}/rsvps', { const { data, error } = await api.POST('/events/{token}/rsvps', {
params: { path: { eventToken: route.params.eventToken as string } }, params: { path: { token: route.params.eventToken as string } },
body: { name: nameInput.value }, body: { name: nameInput.value },
}) })
@@ -380,76 +230,11 @@ 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) { async function fetchAttendees(eventToken: string, organizerToken: string) {
try { try {
const { data, error } = await api.GET('/events/{eventToken}/attendees', { const { data, error } = await api.GET('/events/{token}/attendees', {
params: { params: {
path: { eventToken: eventToken }, path: { token: eventToken },
query: { organizerToken }, query: { organizerToken },
}, },
}) })
@@ -483,19 +268,15 @@ onMounted(fetchEvent)
.detail__hero { .detail__hero {
position: relative; position: relative;
width: 100%; width: 100%;
height: 420px; height: 260px;
overflow: visible; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
} }
.detail__hero-img { .detail__hero-img {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
} }
.detail__hero-overlay { .detail__hero-overlay {
@@ -503,10 +284,36 @@ onMounted(fetchEvent)
inset: 0; inset: 0;
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
var(--color-glass-overlay) 0%, rgba(0, 0, 0, 0.4) 0%,
transparent 50% transparent 50%,
var(--color-gradient-start) 100%
); );
pointer-events: none; }
.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);
} }
.detail__body { .detail__body {
@@ -529,10 +336,6 @@ onMounted(fetchEvent)
padding-top: 4rem; padding-top: 4rem;
} }
.detail__meta-icon svg {
display: block;
}
/* Title */ /* Title */
.detail__title { .detail__title {
font-size: 2rem; font-size: 2rem;
@@ -563,12 +366,9 @@ onMounted(fetchEvent)
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px; border-radius: 10px;
color: var(--color-text-on-gradient); 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 { .detail__meta-text {
@@ -587,14 +387,14 @@ onMounted(fetchEvent)
.detail__section-title { .detail__section-title {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-muted); color: rgba(255, 255, 255, 0.5);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.detail__description { .detail__description {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text-soft); color: rgba(255, 255, 255, 0.85);
line-height: 1.6; line-height: 1.6;
word-break: break-word; word-break: break-word;
} }
@@ -608,6 +408,12 @@ onMounted(fetchEvent)
text-align: center; text-align: center;
} }
.detail__banner--expired {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
}
/* Error / not-found message */ /* Error / not-found message */
.detail__message { .detail__message {
font-size: 1.1rem; font-size: 1.1rem;
@@ -617,7 +423,7 @@ onMounted(fetchEvent)
/* Skeleton shimmer on gradient */ /* Skeleton shimmer on gradient */
.skeleton { .skeleton {
background: linear-gradient(90deg, var(--color-glass) 25%, var(--color-glass-hover) 50%, var(--color-glass) 75%); background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.25) 50%, rgba(255, 255, 255, 0.1) 75%);
background-size: 200% 100%; background-size: 200% 100%;
} }
@@ -636,199 +442,4 @@ onMounted(fetchEvent)
.skeleton--short { .skeleton--short {
width: 45%; width: 45%;
} }
/* RSVP submit button (glow border wrapper) */
.rsvp-form__submit {
width: 100%;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.rsvp-form__submit:hover {
transform: scale(1.02);
}
.rsvp-form__submit:active {
transform: scale(0.98);
}
.rsvp-form__submit-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-form__submit-inner:disabled {
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> </style>

View File

@@ -44,6 +44,7 @@ describe('EventCreateView', () => {
expect(wrapper.find('#description').exists()).toBe(true) expect(wrapper.find('#description').exists()).toBe(true)
expect(wrapper.find('#dateTime').exists()).toBe(true) expect(wrapper.find('#dateTime').exists()).toBe(true)
expect(wrapper.find('#location').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 () => { it('has required attribute on required fields', async () => {
@@ -57,6 +58,7 @@ describe('EventCreateView', () => {
expect(wrapper.find('#title').attributes('required')).toBeDefined() expect(wrapper.find('#title').attributes('required')).toBeDefined()
expect(wrapper.find('#dateTime').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 () => { it('does not have required attribute on optional fields', async () => {
@@ -100,6 +102,7 @@ describe('EventCreateView', () => {
// Fill required fields // Fill required fields
await wrapper.find('#title').setValue('My Event') await wrapper.find('#title').setValue('My Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00') await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
@@ -124,7 +127,7 @@ describe('EventCreateView', () => {
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0) const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
expect(errorsBefore.length).toBeGreaterThanOrEqual(2) expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
// Type into title field // Type into title field
await wrapper.find('#title').setValue('My Event') await wrapper.find('#title').setValue('My Event')
@@ -135,6 +138,9 @@ describe('EventCreateView', () => {
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')! const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(dateTimeError.textContent).not.toBe('') 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 () => { it('shows validation errors when submitting empty form', async () => {
@@ -150,7 +156,7 @@ describe('EventCreateView', () => {
const errorElements = wrapper.findAll('[role="alert"]') const errorElements = wrapper.findAll('[role="alert"]')
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0) const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
expect(errorTexts.length).toBeGreaterThanOrEqual(2) expect(errorTexts.length).toBeGreaterThanOrEqual(3)
}) })
it('submits successfully, saves to storage, and navigates to event page', async () => { it('submits successfully, saves to storage, and navigates to event page', async () => {
@@ -163,9 +169,6 @@ describe('EventCreateView', () => {
getOrganizerToken: vi.fn(), getOrganizerToken: vi.fn(),
saveRsvp: vi.fn(), saveRsvp: vi.fn(),
getRsvp: vi.fn(), getRsvp: vi.fn(),
removeRsvp: vi.fn(),
saveWatch: vi.fn(),
isStored: vi.fn(() => false),
removeEvent: vi.fn(), removeEvent: vi.fn(),
}) })
@@ -176,6 +179,7 @@ describe('EventCreateView', () => {
title: 'Birthday Party', title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00', dateTime: '2026-12-25T18:00:00+01:00',
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
expiryDate: '2026-12-24',
}, },
error: undefined, error: undefined,
response: new Response(), response: new Response(),
@@ -194,6 +198,7 @@ describe('EventCreateView', () => {
await wrapper.find('#description').setValue('Come celebrate!') await wrapper.find('#description').setValue('Come celebrate!')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00') await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#location').setValue('Berlin') await wrapper.find('#location').setValue('Berlin')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
@@ -203,6 +208,7 @@ describe('EventCreateView', () => {
title: 'Birthday Party', title: 'Birthday Party',
description: 'Come celebrate!', description: 'Come celebrate!',
location: 'Berlin', location: 'Berlin',
expiryDate: '2026-12-24',
}), }),
}) })
@@ -211,6 +217,7 @@ describe('EventCreateView', () => {
organizerToken: 'org-456', organizerToken: 'org-456',
title: 'Birthday Party', title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00', dateTime: '2026-12-25T18:00:00+01:00',
expiryDate: '2026-12-24',
}) })
expect(pushSpy).toHaveBeenCalledWith({ expect(pushSpy).toHaveBeenCalledWith({
@@ -238,6 +245,7 @@ describe('EventCreateView', () => {
await wrapper.find('#title').setValue('Duplicate Event') await wrapper.find('#title').setValue('Duplicate Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00') await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
@@ -248,5 +256,6 @@ describe('EventCreateView', () => {
// Other field errors should not be present // Other field errors should not be present
expect(wrapper.find('#dateTime-error').exists()).toBe(false) expect(wrapper.find('#dateTime-error').exists()).toBe(false)
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
}) })
}) })

View File

@@ -14,9 +14,6 @@ vi.mock('@/api/client', () => ({
const mockSaveRsvp = vi.fn() const mockSaveRsvp = vi.fn()
const mockGetRsvp = vi.fn() const mockGetRsvp = vi.fn()
const mockGetOrganizerToken = vi.fn() const mockGetOrganizerToken = vi.fn()
const mockSaveWatch = vi.fn()
const mockIsStored = vi.fn()
const mockRemoveEvent = vi.fn()
vi.mock('@/composables/useEventStorage', () => ({ vi.mock('@/composables/useEventStorage', () => ({
useEventStorage: vi.fn(() => ({ useEventStorage: vi.fn(() => ({
@@ -25,10 +22,7 @@ vi.mock('@/composables/useEventStorage', () => ({
getOrganizerToken: mockGetOrganizerToken, getOrganizerToken: mockGetOrganizerToken,
saveRsvp: mockSaveRsvp, saveRsvp: mockSaveRsvp,
getRsvp: mockGetRsvp, getRsvp: mockGetRsvp,
removeRsvp: vi.fn(), removeEvent: vi.fn(),
saveWatch: mockSaveWatch,
isStored: mockIsStored,
removeEvent: mockRemoveEvent,
})), })),
})) }))
@@ -60,6 +54,7 @@ const fullEvent = {
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
location: 'Central Park, NYC', location: 'Central Park, NYC',
attendeeCount: 12, attendeeCount: 12,
expired: false,
} }
function mockLoadedEvent(eventOverrides = {}) { function mockLoadedEvent(eventOverrides = {}) {
@@ -74,16 +69,6 @@ beforeEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
mockGetRsvp.mockReturnValue(undefined) mockGetRsvp.mockReturnValue(undefined)
mockGetOrganizerToken.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', () => { describe('EventDetailView', () => {
@@ -139,6 +124,29 @@ describe('EventDetailView', () => {
wrapper.unmount() 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 // Not found state
it('renders "event not found" when API returns 404', async () => { it('renders "event not found" when API returns 404', async () => {
vi.mocked(api.GET).mockResolvedValue({ vi.mocked(api.GET).mockResolvedValue({
@@ -204,8 +212,8 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() await flushPromises()
expect(wrapper.find('.bar-cta').exists()).toBe(true) expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.bar-cta').text()).toBe("I'm attending!") expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
wrapper.unmount() wrapper.unmount()
}) })
@@ -216,6 +224,18 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() 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) expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
wrapper.unmount() wrapper.unmount()
}) })
@@ -229,7 +249,7 @@ describe('EventDetailView', () => {
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(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__text').text()).toBe("You're attending!")
expect(wrapper.find('.bar-cta').exists()).toBe(false) expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
wrapper.unmount() wrapper.unmount()
}) })
@@ -242,7 +262,7 @@ describe('EventDetailView', () => {
expect(document.body.querySelector('[role="dialog"]')).toBeNull() expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await wrapper.find('.bar-cta-btn').trigger('click') await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises() await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull() expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
@@ -255,7 +275,7 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() await flushPromises()
await wrapper.find('.bar-cta-btn').trigger('click') await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises() await flushPromises()
// Form is inside Teleport — find via document.body // Form is inside Teleport — find via document.body
@@ -280,7 +300,7 @@ describe('EventDetailView', () => {
await flushPromises() await flushPromises()
// Open sheet // Open sheet
await wrapper.find('.bar-cta-btn').trigger('click') await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises() await flushPromises()
// Fill name via Teleported input // Fill name via Teleported input
@@ -295,8 +315,8 @@ describe('EventDetailView', () => {
await flushPromises() await flushPromises()
// Verify API call // Verify API call
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{eventToken}/rsvps', { expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{token}/rsvps', {
params: { path: { eventToken: 'test-token' } }, params: { path: { token: 'test-token' } },
body: { name: 'Max' }, body: { name: 'Max' },
}) })
@@ -311,7 +331,7 @@ describe('EventDetailView', () => {
// Verify UI switched to status // Verify UI switched to status
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!") expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
expect(wrapper.find('.bar-cta').exists()).toBe(false) expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
// Verify attendee count incremented // Verify attendee count incremented
expect(wrapper.text()).toContain('13') expect(wrapper.text()).toContain('13')
@@ -366,7 +386,7 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() await flushPromises()
await wrapper.find('.bar-cta-btn').trigger('click') await wrapper.find('.rsvp-bar__cta').trigger('click')
await flushPromises() await flushPromises()
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
@@ -381,89 +401,4 @@ describe('EventDetailView', () => {
expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.') expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.')
wrapper.unmount() 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()
})
}) })

View File

@@ -1,36 +0,0 @@
# Specification Quality Checklist: Link Preview (Open Graph Meta-Tags)
**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`.
- Assumptions section documents the key technical consideration (SPA vs. server-rendered meta-tags) without prescribing a solution.
- `og:image` explicitly deferred to future scope.

View File

@@ -1,98 +0,0 @@
# Contract: HTML Meta-Tags
**Feature**: 012-link-preview | **Date**: 2026-03-09
## Overview
This feature does not add new REST API endpoints. The contract is the HTML meta-tag structure injected into the server-rendered `index.html`.
## Meta-Tag Contract: Event Pages
For requests to `/events/{eventToken}` where the event exists:
```html
<!-- Open Graph -->
<meta property="og:title" content="{event title, max 70 chars}">
<meta property="og:description" content="{formatted description, max 200 chars}">
<meta property="og:url" content="{absolute canonical URL}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="fete">
<meta property="og:image" content="{absolute URL}/og-image.png">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{event title, max 70 chars}">
<meta name="twitter:description" content="{formatted description, max 200 chars}">
```
### Description Format
Full event data:
```
📅 Saturday, March 15, 2026 at 7:00 PM · 📍 Berlin — First 200 chars of description...
```
No location:
```
📅 Saturday, March 15, 2026 at 7:00 PM — First 200 chars of description...
```
No description:
```
📅 Saturday, March 15, 2026 at 7:00 PM · 📍 Berlin
```
No location, no description:
```
📅 Saturday, March 15, 2026 at 7:00 PM
```
### Title Truncation
- Titles ≤ 70 characters: used as-is.
- Titles > 70 characters: truncated to 67 characters + "..."
### HTML Escaping
All meta-tag `content` values MUST be HTML-escaped:
- `"``&quot;`
- `&``&amp;`
- `<``&lt;`
- `>``&gt;`
## Meta-Tag Contract: Non-Event Pages
For requests to `/`, `/create`, or any other non-event, non-API, non-static route:
```html
<!-- Open Graph -->
<meta property="og:title" content="fete">
<meta property="og:description" content="Privacy-focused event planning. Create and share events without accounts.">
<meta property="og:url" content="{absolute canonical URL}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="fete">
<meta property="og:image" content="{absolute URL}/og-image.png">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="fete">
<meta name="twitter:description" content="Privacy-focused event planning. Create and share events without accounts.">
```
## Meta-Tag Contract: Event Not Found
For requests to `/events/{eventToken}` where the event does NOT exist, fall back to generic meta-tags (same as non-event pages). The Vue SPA will handle the 404 UI client-side.
## Injection Mechanism
The `index.html` template contains a placeholder:
```html
<head>
...
<!-- OG_META_TAGS -->
...
</head>
```
The server replaces `<!-- OG_META_TAGS -->` with the generated meta-tag block before sending the response.

View File

@@ -1,83 +0,0 @@
# Data Model: Link Preview (Open Graph Meta-Tags)
**Feature**: 012-link-preview | **Date**: 2026-03-09
## Overview
This feature does NOT introduce new database entities. It reads existing event data and projects it into HTML meta-tags. The "model" here is the meta-tag value object used during HTML generation.
## Meta-Tag Value Objects
### OpenGraphMeta
Represents the set of Open Graph meta-tags to inject into the HTML response.
| Field | Type | Source | Rules |
|---|---|---|---|
| `title` | String | Event title or "fete" | Max 70 chars, truncated with "..." |
| `description` | String | Composed from event fields or generic text | Max 200 chars |
| `url` | String | Canonical URL from request | Absolute URL |
| `type` | String | Always "website" | Constant |
| `siteName` | String | Always "fete" | Constant |
| `image` | String | Static brand image URL | Absolute URL to `/og-image.png` |
### TwitterCardMeta
| Field | Type | Source | Rules |
|---|---|---|---|
| `card` | String | Always "summary" | Constant |
| `title` | String | Same as OG title | Max 70 chars |
| `description` | String | Same as OG description | Max 200 chars |
## Data Flow
```
Request for /events/{token}
LinkPreviewController
├── Resolve event token → Event domain object (existing EventRepository)
├── Build OpenGraphMeta from Event fields:
│ title ← event.title (truncated)
│ description ← formatDescription(event.dateTime, event.timezone, event.location, event.description)
│ url ← request base URL + /events/{token}
│ image ← request base URL + /og-image.png
├── Build TwitterCardMeta (mirrors OG values)
├── Inject meta-tags into cached index.html template
└── Return modified HTML
Request for / or /create (non-event pages)
LinkPreviewController
├── Build generic OpenGraphMeta:
│ title ← "fete"
│ description ← "Privacy-focused event planning. Create and share events without accounts."
│ url ← request URL
│ image ← request base URL + /og-image.png
├── Build generic TwitterCardMeta
├── Inject meta-tags into cached index.html template
└── Return modified HTML
```
## Existing Entities Used (Read-Only)
### Event (from `de.fete.domain.model.Event`)
| Field | Used For |
|---|---|
| `title` | `og:title`, `twitter:title` |
| `description` | Part of `og:description`, `twitter:description` |
| `dateTime` | Part of `og:description` (formatted) |
| `timezone` | Date formatting context |
| `location` | Part of `og:description` |
| `eventToken` | URL construction |

View File

@@ -1,83 +0,0 @@
# Implementation Plan: Link Preview (Open Graph Meta-Tags)
**Branch**: `012-link-preview` | **Date**: 2026-03-09 | **Spec**: `specs/012-link-preview/spec.md`
**Input**: Feature specification from `/specs/012-link-preview/spec.md`
## Summary
Inject Open Graph and Twitter Card meta-tags into the server-rendered HTML so that shared event links display rich preview cards in messengers and on social media. The Spring Boot backend replaces its current static SPA fallback with a controller that dynamically injects event-specific or generic meta-tags into the cached `index.html` template before serving it.
## Technical Context
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend — minimal changes)
**Primary Dependencies**: Spring Boot 3.5.x (existing), Vue 3 (existing) — no new dependencies
**Storage**: PostgreSQL via JPA (existing event data, read-only access)
**Testing**: JUnit 5 + Spring MockMvc (backend), Playwright (E2E)
**Target Platform**: Self-hosted Docker container (Linux)
**Project Type**: Web application (SPA + REST API)
**Performance Goals**: N/A — meta-tag injection adds negligible overhead (<1ms string replacement)
**Constraints**: Meta-tags MUST be in initial HTML response (no client-side JS injection). No external services or CDNs.
**Scale/Scope**: Affects all HTML page responses. No new database tables or API endpoints.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|---|---|---|
| I. Privacy by Design | ✅ PASS | No tracking, analytics, or external services. Meta-tags contain only public event info (title, date, location). No PII exposed. `og:image` is a self-hosted static asset. |
| II. Test-Driven Methodology | ✅ PLAN | Unit tests for meta-tag generation, integration tests for controller, E2E tests for full HTML verification. TDD enforced. |
| III. API-First Development | ✅ N/A | No new API endpoints. This feature modifies HTML serving, not the REST API. Existing OpenAPI spec unchanged. |
| IV. Simplicity & Quality | ✅ PASS | Simple string replacement in cached HTML template. No SSR framework, no prerendering service, no user-agent sniffing. Minimal moving parts. |
| V. Dependency Discipline | ✅ PASS | Zero new dependencies. Uses only Spring Boot's existing capabilities. |
| VI. Accessibility | ✅ N/A | Meta-tags are invisible to users. No UI changes. |
**Gate result: PASS** — No violations. No complexity tracking needed.
## Project Structure
### Documentation (this feature)
```text
specs/012-link-preview/
├── plan.md # This file
├── research.md # Phase 0 output — technical decisions
├── data-model.md # Phase 1 output — meta-tag value objects
├── quickstart.md # Phase 1 output — implementation guide
├── contracts/
│ └── html-meta-tags.md # Phase 1 output — meta-tag HTML contract
└── tasks.md # Phase 2 output (created by /speckit.tasks)
```
### Source Code (repository root)
```text
backend/
├── src/main/java/de/fete/
│ ├── adapter/in/web/
│ │ └── SpaController.java # NEW — serves index.html with injected meta-tags
│ ├── application/
│ │ └── OpenGraphService.java # NEW — composes meta-tag values from event data
│ └── config/
│ └── WebConfig.java # MODIFIED — remove PathResourceResolver SPA fallback
├── src/main/resources/
│ └── application.properties # MODIFIED — add forward-headers-strategy
└── src/test/java/de/fete/
├── adapter/in/web/
│ └── SpaControllerTest.java # NEW — integration tests
└── application/
└── OpenGraphServiceTest.java # NEW — unit tests
frontend/
├── index.html # MODIFIED — add <!-- OG_META_TAGS --> placeholder
├── public/
│ └── og-image.png # NEW — brand image for og:image (1200x630)
└── e2e/
└── link-preview.spec.ts # NEW — E2E tests
```
**Structure Decision**: Web application structure (existing). Backend changes in adapter/web and application layers. Frontend changes minimal (HTML placeholder + static asset).
## Complexity Tracking
> No violations — section intentionally empty.

Some files were not shown because too many files have changed in this diff Show More