Compare commits
71 Commits
0.5.0
...
2f8b911af8
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f8b911af8 | |||
| e9791de4e2 | |||
| 3b4cc7fbb9 | |||
| 9c0e9249ce | |||
| 5082ec1333 | |||
| 35b488a8be | |||
| b067c0ef1e | |||
|
|
42686502d8 | ||
| 51ab99fc61 | |||
| d52f51d6e1 | |||
| c1760ae376 | |||
| 6d51327e56 | |||
| 96044ae1ed | |||
| f972a41e45 | |||
| 13b01dfba8 | |||
| fd8724db8f | |||
| 8885dbd722 | |||
| c51eacb261 | |||
| c450849e4d | |||
| e01d5ee642 | |||
| d333ab3d39 | |||
| 541017965f | |||
| 981920f004 | |||
| 3908c89998 | |||
| bf0f4ffb7f | |||
| 58043d1507 | |||
|
|
264c4ec21f | ||
| 6d7a55fdb3 | |||
| a8aacf4ee9 | |||
| 0a404ecde3 | |||
| 01f9e3dac1 | |||
| ad607afe83 | |||
| f0424223de | |||
| 7ab9068c14 | |||
| 41bb17d5c9 | |||
|
|
a44b938f08 | ||
|
|
7477a953c5 | ||
|
|
7fb296b47f | ||
|
|
8ab7d345c8 | ||
|
|
cf2139f229 | ||
|
|
79f33d659c | ||
| e5b71f8fb8 | |||
|
|
60649ae4de | ||
| e90aefae15 | |||
|
|
622932418d | ||
| a1855ff8d6 | |||
| 4bfaee685c | |||
| 2a6a658df9 | |||
| 37d378ca59 | |||
| 0441ca0c33 | |||
|
|
e6711b33d4 | ||
| 6b3a06a72c | |||
| 448e801ca3 | |||
| 751201617d | |||
| fa34223c10 | |||
| e6ea9405a6 | |||
| 32f96e4c6f | |||
| e6c4a21f65 | |||
| 831ffc071a | |||
| 5dd7cb3fb8 | |||
| 64816558c1 | |||
| 019ead7be3 | |||
| 29974704d0 | |||
| 877c869a22 | |||
| a9743025a7 | |||
| 9f82275c63 | |||
| e203ecf687 | |||
| aa3ea04bfc | |||
|
|
27ca8ab4b8 | ||
| 752d153cd4 | |||
| 763811fce6 |
@@ -180,6 +180,7 @@ Organisator kann Event absagen (mit optionaler Nachricht, Einweg-Transition).
|
|||||||
* RSVPs werden nach Absage abgelehnt
|
* RSVPs werden nach Absage abgelehnt
|
||||||
* Absage-Nachricht nachträglich editierbar
|
* Absage-Nachricht nachträglich editierbar
|
||||||
* Kann nicht rückgängig gemacht werden
|
* Kann nicht rückgängig gemacht werden
|
||||||
|
* Wenn Organisator Event auf der Eventlistenseite löscht, muss dabei das Event abgesagt werden (nicht nur lokal entfernen)
|
||||||
|
|
||||||
### 025 – Event löschen
|
### 025 – Event löschen
|
||||||
Organisator löscht Event permanent und unwiderruflich.
|
Organisator löscht Event permanent und unwiderruflich.
|
||||||
|
|||||||
37
.specify/memory/research/modern-ui-effects.md
Normal file
37
.specify/memory/research/modern-ui-effects.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 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
|
||||||
@@ -51,10 +51,8 @@ The following skills are available and should be used for their respective purpo
|
|||||||
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
|
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
|
- TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite (018-cancel-event-list)
|
||||||
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
- localStorage via `useEventStorage()` composable (018-cancel-event-list)
|
||||||
- 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
|
||||||
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
- 018-cancel-event-list: Added TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Build frontend
|
# Stage 1: Build frontend
|
||||||
FROM node:24-alpine AS frontend-build
|
FROM node:25-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
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -1,6 +1,25 @@
|
|||||||
# fete
|
<p align="center">
|
||||||
|
<img src="frontend/public/og-image.png" alt="fete" width="100%" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A privacy-focused, self-hostable web app for event announcements and RSVPs. An alternative to Facebook Events or Telegram groups — reduced to the essentials.
|
<p align="center">
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/02-event-detail.png" alt="Event Detail" width="230" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/03-rsvp.png" alt="RSVP" width="230" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Create events · Share with guests · Collect RSVPs</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
|||||||
@@ -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.13/apache-maven-3.9.13-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||||
|
|||||||
@@ -7,4 +7,8 @@
|
|||||||
<Match>
|
<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>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ 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. */
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
import de.fete.adapter.in.web.api.EventsApi;
|
import de.fete.adapter.in.web.api.EventsApi;
|
||||||
|
import de.fete.adapter.in.web.model.Attendee;
|
||||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||||
import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
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.GetEventResponse;
|
import de.fete.adapter.in.web.model.GetEventResponse;
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.adapter.in.web.model.PatchEventRequest;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidTimezoneException;
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
import de.fete.domain.model.EventToken;
|
import de.fete.domain.model.EventToken;
|
||||||
|
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.GetEventUseCase;
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
import java.time.Clock;
|
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||||
import java.time.DateTimeException;
|
import java.time.DateTimeException;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -32,21 +39,27 @@ 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 Clock clock;
|
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||||
|
private final UpdateEventUseCase updateEventUseCase;
|
||||||
|
|
||||||
/** Creates a new controller with the given use cases and clock. */
|
/** Creates a new controller with the given use cases. */
|
||||||
public EventController(
|
public EventController(
|
||||||
CreateEventUseCase createEventUseCase,
|
CreateEventUseCase createEventUseCase,
|
||||||
GetEventUseCase getEventUseCase,
|
GetEventUseCase getEventUseCase,
|
||||||
CreateRsvpUseCase createRsvpUseCase,
|
CreateRsvpUseCase createRsvpUseCase,
|
||||||
|
CancelRsvpUseCase cancelRsvpUseCase,
|
||||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||||
Clock clock) {
|
GetAttendeesUseCase getAttendeesUseCase,
|
||||||
|
UpdateEventUseCase updateEventUseCase) {
|
||||||
this.createEventUseCase = createEventUseCase;
|
this.createEventUseCase = createEventUseCase;
|
||||||
this.getEventUseCase = getEventUseCase;
|
this.getEventUseCase = getEventUseCase;
|
||||||
this.createRsvpUseCase = createRsvpUseCase;
|
this.createRsvpUseCase = createRsvpUseCase;
|
||||||
|
this.cancelRsvpUseCase = cancelRsvpUseCase;
|
||||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||||
this.clock = clock;
|
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||||
|
this.updateEventUseCase = updateEventUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -59,57 +72,91 @@ 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.getEventToken().value());
|
response.setEventToken(event.eventToken().value());
|
||||||
response.setOrganizerToken(event.getOrganizerToken().value());
|
response.setOrganizerToken(event.organizerToken().value());
|
||||||
response.setTitle(event.getTitle());
|
response.setTitle(event.title());
|
||||||
response.setDateTime(event.getDateTime());
|
response.setDateTime(event.dateTime());
|
||||||
response.setTimezone(event.getTimezone().getId());
|
response.setTimezone(event.timezone().getId());
|
||||||
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 token) {
|
public ResponseEntity<GetEventResponse> getEvent(UUID eventToken) {
|
||||||
var eventToken = new de.fete.domain.model.EventToken(token);
|
var evtToken = new EventToken(eventToken);
|
||||||
Event event = getEventUseCase.getByEventToken(eventToken)
|
Event event = getEventUseCase.getByEventToken(evtToken)
|
||||||
.orElseThrow(() -> new EventNotFoundException(token));
|
.orElseThrow(() -> new EventNotFoundException(eventToken));
|
||||||
|
|
||||||
var response = new GetEventResponse();
|
var response = new GetEventResponse();
|
||||||
response.setEventToken(event.getEventToken().value());
|
response.setEventToken(event.eventToken().value());
|
||||||
response.setTitle(event.getTitle());
|
response.setTitle(event.title());
|
||||||
response.setDescription(event.getDescription());
|
response.setDescription(event.description());
|
||||||
response.setDateTime(event.getDateTime());
|
response.setDateTime(event.dateTime());
|
||||||
response.setTimezone(event.getTimezone().getId());
|
response.setTimezone(event.timezone().getId());
|
||||||
response.setLocation(event.getLocation());
|
response.setLocation(event.location());
|
||||||
response.setAttendeeCount(
|
response.setAttendeeCount(
|
||||||
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
|
(int) countAttendeesByEventUseCase.countByEvent(evtToken));
|
||||||
response.setExpired(
|
response.setCancelled(event.cancelled());
|
||||||
event.getExpiryDate().isBefore(LocalDate.now(clock)));
|
response.setCancellationReason(event.cancellationReason());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> patchEvent(
|
||||||
|
UUID eventToken, UUID organizerToken, PatchEventRequest request) {
|
||||||
|
updateEventUseCase.cancelEvent(
|
||||||
|
new EventToken(eventToken),
|
||||||
|
new OrganizerToken(organizerToken),
|
||||||
|
request.getCancelled(),
|
||||||
|
request.getCancellationReason());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<GetAttendeesResponse> getAttendees(
|
||||||
|
UUID eventToken, UUID organizerToken) {
|
||||||
|
var evtToken = new EventToken(eventToken);
|
||||||
|
var orgToken = new OrganizerToken(organizerToken);
|
||||||
|
|
||||||
|
List<String> names = getAttendeesUseCase
|
||||||
|
.getAttendeeNames(evtToken, orgToken);
|
||||||
|
|
||||||
|
var attendees = names.stream()
|
||||||
|
.map(name -> new Attendee().name(name))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var response = new GetAttendeesResponse();
|
||||||
|
response.setAttendees(attendees);
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
||||||
UUID token, CreateRsvpRequest createRsvpRequest) {
|
UUID eventToken, CreateRsvpRequest createRsvpRequest) {
|
||||||
var eventToken = new EventToken(token);
|
var evtToken = new EventToken(eventToken);
|
||||||
Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName());
|
Rsvp rsvp = createRsvpUseCase.createRsvp(evtToken, createRsvpRequest.getName());
|
||||||
|
|
||||||
var response = new CreateRsvpResponse();
|
var response = new CreateRsvpResponse();
|
||||||
response.setRsvpToken(rsvp.getRsvpToken().value());
|
response.setRsvpToken(rsvp.rsvpToken().value());
|
||||||
response.setName(rsvp.getName());
|
response.setName(rsvp.name());
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
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);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
import de.fete.application.service.EventExpiredException;
|
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.application.service.exception.EventCancelledException;
|
||||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
import de.fete.application.service.exception.EventExpiredException;
|
||||||
import de.fete.application.service.ExpiryDateInPastException;
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.exception.ExpiryDateBeforeEventException;
|
||||||
|
import de.fete.application.service.exception.ExpiryDateInPastException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
|
import de.fete.application.service.exception.InvalidTimezoneException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -74,6 +77,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
.body(problemDetail);
|
.body(problemDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handles attempt to cancel an already cancelled event. */
|
||||||
|
@ExceptionHandler(EventAlreadyCancelledException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleEventAlreadyCancelled(
|
||||||
|
EventAlreadyCancelledException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Event Already Cancelled");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:event-already-cancelled"));
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles RSVP on cancelled event. */
|
||||||
|
@ExceptionHandler(EventCancelledException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleEventCancelled(
|
||||||
|
EventCancelledException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Event Cancelled");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:event-cancelled"));
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
/** Handles RSVP on expired event. */
|
/** Handles RSVP on expired event. */
|
||||||
@ExceptionHandler(EventExpiredException.class)
|
@ExceptionHandler(EventExpiredException.class)
|
||||||
public ResponseEntity<ProblemDetail> handleEventExpired(
|
public ResponseEntity<ProblemDetail> handleEventExpired(
|
||||||
@@ -87,6 +116,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
.body(problemDetail);
|
.body(problemDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handles invalid organizer token. */
|
||||||
|
@ExceptionHandler(InvalidOrganizerTokenException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleInvalidOrganizerToken(
|
||||||
|
InvalidOrganizerTokenException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.FORBIDDEN, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Forbidden");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:invalid-organizer-token"));
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
/** Handles event not found. */
|
/** Handles event not found. */
|
||||||
@ExceptionHandler(EventNotFoundException.class)
|
@ExceptionHandler(EventNotFoundException.class)
|
||||||
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
||||||
|
|||||||
188
backend/src/main/java/de/fete/adapter/in/web/SpaController.java
Normal file
188
backend/src/main/java/de/fete/adapter/in/web/SpaController.java
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
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("&", "&")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getBaseUrl(HttpServletRequest request) {
|
||||||
|
return ServletUriComponentsBuilder.fromRequestUri(request)
|
||||||
|
.replacePath("")
|
||||||
|
.build()
|
||||||
|
.toUriString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,12 @@ public class EventJpaEntity {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "cancelled", nullable = false)
|
||||||
|
private boolean cancelled;
|
||||||
|
|
||||||
|
@Column(name = "cancellation_reason", length = 2000)
|
||||||
|
private String cancellationReason;
|
||||||
|
|
||||||
/** Returns the internal database ID. */
|
/** Returns the internal database ID. */
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
@@ -145,4 +151,24 @@ public class EventJpaEntity {
|
|||||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns whether the event is cancelled. */
|
||||||
|
public boolean isCancelled() {
|
||||||
|
return cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the cancelled flag. */
|
||||||
|
public void setCancelled(boolean cancelled) {
|
||||||
|
this.cancelled = cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the cancellation reason. */
|
||||||
|
public String getCancellationReason() {
|
||||||
|
return cancellationReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the cancellation reason. */
|
||||||
|
public void setCancellationReason(String cancellationReason) {
|
||||||
|
this.cancellationReason = cancellationReason;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,33 +31,41 @@ 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.getId());
|
entity.setId(event.id());
|
||||||
entity.setEventToken(event.getEventToken().value());
|
entity.setEventToken(event.eventToken().value());
|
||||||
entity.setOrganizerToken(event.getOrganizerToken().value());
|
entity.setOrganizerToken(event.organizerToken().value());
|
||||||
entity.setTitle(event.getTitle());
|
entity.setTitle(event.title());
|
||||||
entity.setDescription(event.getDescription());
|
entity.setDescription(event.description());
|
||||||
entity.setDateTime(event.getDateTime());
|
entity.setDateTime(event.dateTime());
|
||||||
entity.setTimezone(event.getTimezone().getId());
|
entity.setTimezone(event.timezone().getId());
|
||||||
entity.setLocation(event.getLocation());
|
entity.setLocation(event.location());
|
||||||
entity.setExpiryDate(event.getExpiryDate());
|
entity.setExpiryDate(event.expiryDate());
|
||||||
entity.setCreatedAt(event.getCreatedAt());
|
entity.setCreatedAt(event.createdAt());
|
||||||
|
entity.setCancelled(event.cancelled());
|
||||||
|
entity.setCancellationReason(event.cancellationReason());
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Event toDomain(EventJpaEntity entity) {
|
private Event toDomain(EventJpaEntity entity) {
|
||||||
var event = new Event();
|
return new Event(
|
||||||
event.setId(entity.getId());
|
entity.getId(),
|
||||||
event.setEventToken(new EventToken(entity.getEventToken()));
|
new EventToken(entity.getEventToken()),
|
||||||
event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
|
new OrganizerToken(entity.getOrganizerToken()),
|
||||||
event.setTitle(entity.getTitle());
|
entity.getTitle(),
|
||||||
event.setDescription(entity.getDescription());
|
entity.getDescription(),
|
||||||
event.setDateTime(entity.getDateTime());
|
entity.getDateTime(),
|
||||||
event.setTimezone(ZoneId.of(entity.getTimezone()));
|
ZoneId.of(entity.getTimezone()),
|
||||||
event.setLocation(entity.getLocation());
|
entity.getLocation(),
|
||||||
event.setExpiryDate(entity.getExpiryDate());
|
entity.getExpiryDate(),
|
||||||
event.setCreatedAt(entity.getCreatedAt());
|
entity.getCreatedAt(),
|
||||||
return event;
|
entity.isCancelled(),
|
||||||
|
entity.getCancellationReason());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,10 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
|
|||||||
|
|
||||||
/** Counts RSVPs for the given event. */
|
/** Counts RSVPs for the given event. */
|
||||||
long countByEventId(Long eventId);
|
long countByEventId(Long eventId);
|
||||||
|
|
||||||
|
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||||
|
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
|
||||||
|
|
||||||
|
/** Deletes an RSVP by event ID and RSVP token. Returns count of deleted rows. */
|
||||||
|
long deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.fete.adapter.out.persistence;
|
|||||||
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.out.RsvpRepository;
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
/** Persistence adapter implementing the RsvpRepository outbound port. */
|
/** Persistence adapter implementing the RsvpRepository outbound port. */
|
||||||
@@ -28,21 +29,32 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
|
|||||||
return jpaRepository.countByEventId(eventId);
|
return jpaRepository.countByEventId(eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Rsvp> findByEventId(Long eventId) {
|
||||||
|
return jpaRepository.findAllByEventIdOrderByIdAsc(eventId).stream()
|
||||||
|
.map(this::toDomain)
|
||||||
|
.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.getId());
|
entity.setId(rsvp.id());
|
||||||
entity.setRsvpToken(rsvp.getRsvpToken().value());
|
entity.setRsvpToken(rsvp.rsvpToken().value());
|
||||||
entity.setEventId(rsvp.getEventId());
|
entity.setEventId(rsvp.eventId());
|
||||||
entity.setName(rsvp.getName());
|
entity.setName(rsvp.name());
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Rsvp toDomain(RsvpJpaEntity entity) {
|
private Rsvp toDomain(RsvpJpaEntity entity) {
|
||||||
var rsvp = new Rsvp();
|
return new Rsvp(
|
||||||
rsvp.setId(entity.getId());
|
entity.getId(),
|
||||||
rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken()));
|
new RsvpToken(entity.getRsvpToken()),
|
||||||
rsvp.setEventId(entity.getEventId());
|
entity.getEventId(),
|
||||||
rsvp.setName(entity.getName());
|
entity.getName());
|
||||||
return rsvp;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
import de.fete.domain.model.EventToken;
|
import de.fete.domain.model.EventToken;
|
||||||
import de.fete.domain.model.OrganizerToken;
|
import de.fete.domain.model.OrganizerToken;
|
||||||
import de.fete.domain.port.in.CreateEventUseCase;
|
import de.fete.domain.port.in.CreateEventUseCase;
|
||||||
import de.fete.domain.port.in.GetEventUseCase;
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
|
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/** Application service implementing event creation and retrieval. */
|
/** Application service implementing event creation and retrieval. */
|
||||||
@Service
|
@Service
|
||||||
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
public class EventService implements CreateEventUseCase, GetEventUseCase, UpdateEventUseCase {
|
||||||
|
|
||||||
|
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
|
||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
@@ -28,24 +35,21 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Event createEvent(CreateEventCommand command) {
|
public Event createEvent(CreateEventCommand command) {
|
||||||
if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
|
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
|
||||||
throw new ExpiryDateInPastException(command.expiryDate());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) {
|
var event = new Event(
|
||||||
throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
|
null,
|
||||||
}
|
EventToken.generate(),
|
||||||
|
OrganizerToken.generate(),
|
||||||
var event = new Event();
|
command.title(),
|
||||||
event.setEventToken(EventToken.generate());
|
command.description(),
|
||||||
event.setOrganizerToken(OrganizerToken.generate());
|
command.dateTime(),
|
||||||
event.setTitle(command.title());
|
command.timezone(),
|
||||||
event.setDescription(command.description());
|
command.location(),
|
||||||
event.setDateTime(command.dateTime());
|
expiryDate,
|
||||||
event.setTimezone(command.timezone());
|
OffsetDateTime.now(clock),
|
||||||
event.setLocation(command.location());
|
false,
|
||||||
event.setExpiryDate(command.expiryDate());
|
null);
|
||||||
event.setCreatedAt(OffsetDateTime.now(clock));
|
|
||||||
|
|
||||||
return eventRepository.save(event);
|
return eventRepository.save(event);
|
||||||
}
|
}
|
||||||
@@ -54,4 +58,27 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
|||||||
public Optional<Event> getByEventToken(EventToken eventToken) {
|
public Optional<Event> getByEventToken(EventToken eventToken) {
|
||||||
return eventRepository.findByEventToken(eventToken);
|
return eventRepository.findByEventToken(eventToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public void cancelEvent(
|
||||||
|
EventToken eventToken, OrganizerToken organizerToken,
|
||||||
|
Boolean cancelled, String reason) {
|
||||||
|
if (!Boolean.TRUE.equals(cancelled)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
|
if (!event.organizerToken().equals(organizerToken)) {
|
||||||
|
throw new InvalidOrganizerTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.cancelled()) {
|
||||||
|
throw new EventAlreadyCancelledException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
eventRepository.save(event.withCancellation(true, reason));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** Scheduled job that deletes events whose expiry date is in the past. */
|
||||||
|
@Component
|
||||||
|
public class ExpiredEventCleanupJob {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ExpiredEventCleanupJob.class);
|
||||||
|
|
||||||
|
private final EventRepository eventRepository;
|
||||||
|
|
||||||
|
/** Creates a new cleanup job with the given event repository. */
|
||||||
|
public ExpiredEventCleanupJob(EventRepository eventRepository) {
|
||||||
|
this.eventRepository = eventRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs daily at 03:00 and deletes all expired events. */
|
||||||
|
@Scheduled(cron = "0 0 3 * * *")
|
||||||
|
@Transactional
|
||||||
|
public void deleteExpiredEvents() {
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
log.info("Expired event cleanup: deleted {} event(s)", deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,31 @@
|
|||||||
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.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.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 org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/** Application service implementing RSVP creation. */
|
/** Application service implementing RSVP operations. */
|
||||||
@Service
|
@Service
|
||||||
public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase {
|
public class RsvpService
|
||||||
|
implements CreateRsvpUseCase, CancelRsvpUseCase, CountAttendeesByEventUseCase,
|
||||||
|
GetAttendeesUseCase {
|
||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final RsvpRepository rsvpRepository;
|
private final RsvpRepository rsvpRepository;
|
||||||
@@ -35,22 +46,45 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
|
|||||||
Event event = eventRepository.findByEventToken(eventToken)
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
|
if (event.cancelled()) {
|
||||||
|
throw new EventCancelledException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.expiryDate().isAfter(LocalDate.now(clock))) {
|
||||||
throw new EventExpiredException(eventToken.value());
|
throw new EventExpiredException(eventToken.value());
|
||||||
}
|
}
|
||||||
|
|
||||||
var rsvp = new Rsvp();
|
var rsvp = new Rsvp(null, RsvpToken.generate(), event.id(), name.strip());
|
||||||
rsvp.setRsvpToken(RsvpToken.generate());
|
|
||||||
rsvp.setEventId(event.getId());
|
|
||||||
rsvp.setName(name.strip());
|
|
||||||
|
|
||||||
return rsvpRepository.save(rsvp);
|
return rsvpRepository.save(rsvp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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.getId());
|
return rsvpRepository.countByEventId(event.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
|
||||||
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
|
if (!event.organizerToken().equals(organizerToken)) {
|
||||||
|
throw new InvalidOrganizerTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rsvpRepository.findByEventId(event.id()).stream()
|
||||||
|
.map(Rsvp::name)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Thrown when attempting to cancel an event that is already cancelled. */
|
||||||
|
public class EventAlreadyCancelledException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for the given event token. */
|
||||||
|
public EventAlreadyCancelledException(UUID eventToken) {
|
||||||
|
super("Event is already cancelled: " + eventToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Thrown when an RSVP is attempted on a cancelled event. */
|
||||||
|
public class EventCancelledException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for the given event token. */
|
||||||
|
public EventCancelledException(UUID eventToken) {
|
||||||
|
super("Event is cancelled: " + eventToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
/** Thrown when an invalid organizer token is provided. */
|
||||||
|
public class InvalidOrganizerTokenException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for an invalid organizer token. */
|
||||||
|
public InvalidOrganizerTokenException() {
|
||||||
|
super("Invalid organizer token.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
/** Thrown when an invalid IANA timezone ID is provided. */
|
/** Thrown when an invalid IANA timezone ID is provided. */
|
||||||
public class InvalidTimezoneException extends RuntimeException {
|
public class InvalidTimezoneException extends RuntimeException {
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Application-layer exceptions thrown by service use case implementations.
|
||||||
|
*/
|
||||||
|
package de.fete.application.service.exception;
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
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 and SPA static resource serving. */
|
/** Configures API path prefix. Static resources served by default Spring Boot handler. */
|
||||||
@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();
|
||||||
@@ -25,23 +21,4 @@ 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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,5 @@ public record CreateEventCommand(
|
|||||||
String description,
|
String description,
|
||||||
OffsetDateTime dateTime,
|
OffsetDateTime dateTime,
|
||||||
ZoneId timezone,
|
ZoneId timezone,
|
||||||
String location,
|
String location
|
||||||
LocalDate expiryDate
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -5,116 +5,26 @@ import java.time.OffsetDateTime;
|
|||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/** Domain entity representing an event. */
|
/** Domain entity representing an event. */
|
||||||
public class Event {
|
public record Event(
|
||||||
|
Long id,
|
||||||
|
EventToken eventToken,
|
||||||
|
OrganizerToken organizerToken,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
OffsetDateTime dateTime,
|
||||||
|
ZoneId timezone,
|
||||||
|
String location,
|
||||||
|
LocalDate expiryDate,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
boolean cancelled,
|
||||||
|
String cancellationReason
|
||||||
|
) {
|
||||||
|
|
||||||
private Long id;
|
/** Returns a copy of this event with cancellation applied. */
|
||||||
private EventToken eventToken;
|
public Event withCancellation(boolean cancelled, String cancellationReason) {
|
||||||
private OrganizerToken organizerToken;
|
return new Event(
|
||||||
private String title;
|
id, eventToken, organizerToken, title, description,
|
||||||
private String description;
|
dateTime, timezone, location, expiryDate, createdAt,
|
||||||
private OffsetDateTime dateTime;
|
cancelled, cancellationReason);
|
||||||
private ZoneId timezone;
|
|
||||||
private String location;
|
|
||||||
private LocalDate expiryDate;
|
|
||||||
private OffsetDateTime createdAt;
|
|
||||||
|
|
||||||
/** Returns the internal database ID. */
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the internal database ID. */
|
|
||||||
public void setId(Long id) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the public event token. */
|
|
||||||
public EventToken getEventToken() {
|
|
||||||
return eventToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the public event token. */
|
|
||||||
public void setEventToken(EventToken eventToken) {
|
|
||||||
this.eventToken = eventToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the secret organizer token. */
|
|
||||||
public OrganizerToken getOrganizerToken() {
|
|
||||||
return organizerToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the secret organizer token. */
|
|
||||||
public void setOrganizerToken(OrganizerToken organizerToken) {
|
|
||||||
this.organizerToken = organizerToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event title. */
|
|
||||||
public String getTitle() {
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event title. */
|
|
||||||
public void setTitle(String title) {
|
|
||||||
this.title = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event description. */
|
|
||||||
public String getDescription() {
|
|
||||||
return description;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event description. */
|
|
||||||
public void setDescription(String description) {
|
|
||||||
this.description = description;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event date and time with UTC offset. */
|
|
||||||
public OffsetDateTime getDateTime() {
|
|
||||||
return dateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event date and time. */
|
|
||||||
public void setDateTime(OffsetDateTime dateTime) {
|
|
||||||
this.dateTime = dateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the IANA timezone. */
|
|
||||||
public ZoneId getTimezone() {
|
|
||||||
return timezone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the IANA timezone. */
|
|
||||||
public void setTimezone(ZoneId timezone) {
|
|
||||||
this.timezone = timezone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event location. */
|
|
||||||
public String getLocation() {
|
|
||||||
return location;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event location. */
|
|
||||||
public void setLocation(String location) {
|
|
||||||
this.location = location;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the expiry date after which event data is deleted. */
|
|
||||||
public LocalDate getExpiryDate() {
|
|
||||||
return expiryDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the expiry date. */
|
|
||||||
public void setExpiryDate(LocalDate expiryDate) {
|
|
||||||
this.expiryDate = expiryDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the creation timestamp. */
|
|
||||||
public OffsetDateTime getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the creation timestamp. */
|
|
||||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
|
||||||
this.createdAt = createdAt;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,9 @@
|
|||||||
package de.fete.domain.model;
|
package de.fete.domain.model;
|
||||||
|
|
||||||
/** Domain entity representing an RSVP. */
|
/** Domain entity representing an RSVP. */
|
||||||
public class Rsvp {
|
public record Rsvp(
|
||||||
|
Long id,
|
||||||
private Long id;
|
RsvpToken rsvpToken,
|
||||||
private RsvpToken rsvpToken;
|
Long eventId,
|
||||||
private Long eventId;
|
String name
|
||||||
private String name;
|
) {}
|
||||||
|
|
||||||
/** Returns the internal database ID. */
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the internal database ID. */
|
|
||||||
public void setId(Long id) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the RSVP token. */
|
|
||||||
public RsvpToken getRsvpToken() {
|
|
||||||
return rsvpToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the RSVP token. */
|
|
||||||
public void setRsvpToken(RsvpToken rsvpToken) {
|
|
||||||
this.rsvpToken = rsvpToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event ID this RSVP belongs to. */
|
|
||||||
public Long getEventId() {
|
|
||||||
return eventId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event ID. */
|
|
||||||
public void setEventId(Long eventId) {
|
|
||||||
this.eventId = eventId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the guest's display name. */
|
|
||||||
public String getName() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the guest's display name. */
|
|
||||||
public void setName(String name) {
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.RsvpToken;
|
||||||
|
|
||||||
|
/** Inbound port for cancelling an RSVP. */
|
||||||
|
public interface CancelRsvpUseCase {
|
||||||
|
|
||||||
|
/** Cancels the RSVP identified by the given tokens. Idempotent — no error if not found. */
|
||||||
|
void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Inbound port for retrieving attendee names of an event. */
|
||||||
|
public interface GetAttendeesUseCase {
|
||||||
|
|
||||||
|
/** Returns attendee names ordered by RSVP submission time. */
|
||||||
|
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
|
||||||
|
/** Inbound port for updating an event. */
|
||||||
|
public interface UpdateEventUseCase {
|
||||||
|
|
||||||
|
/** Cancels the event identified by the given token. */
|
||||||
|
void cancelEvent(
|
||||||
|
EventToken eventToken, OrganizerToken organizerToken,
|
||||||
|
Boolean cancelled, String reason);
|
||||||
|
}
|
||||||
@@ -12,4 +12,7 @@ public interface EventRepository {
|
|||||||
|
|
||||||
/** Finds an event by its public event token. */
|
/** 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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;
|
||||||
|
|
||||||
/** Outbound port for persisting and querying RSVPs. */
|
/** Outbound port for persisting and querying RSVPs. */
|
||||||
public interface RsvpRepository {
|
public interface RsvpRepository {
|
||||||
@@ -10,4 +12,10 @@ public interface RsvpRepository {
|
|||||||
|
|
||||||
/** Counts the number of RSVPs for the given event. */
|
/** Counts the number of RSVPs for the given event. */
|
||||||
long countByEventId(Long eventId);
|
long countByEventId(Long eventId);
|
||||||
|
|
||||||
|
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||||
|
List<Rsvp> findByEventId(Long eventId);
|
||||||
|
|
||||||
|
/** Deletes an RSVP by event ID and RSVP token. Returns true if a record was deleted. */
|
||||||
|
boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="004-add-cancellation-columns" author="fete">
|
||||||
|
<addColumn tableName="events">
|
||||||
|
<column name="cancelled" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="cancellation_reason" type="VARCHAR(2000)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -9,5 +9,6 @@
|
|||||||
<include file="db/changelog/001-create-events-table.xml"/>
|
<include file="db/changelog/001-create-events-table.xml"/>
|
||||||
<include file="db/changelog/002-add-timezone-column.xml"/>
|
<include file="db/changelog/002-add-timezone-column.xml"/>
|
||||||
<include file="db/changelog/003-create-rsvps-table.xml"/>
|
<include file="db/changelog/003-create-rsvps-table.xml"/>
|
||||||
|
<include file="db/changelog/004-add-cancellation-columns.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
@@ -37,14 +37,46 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ValidationProblemDetail"
|
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||||
|
|
||||||
/events/{token}/rsvps:
|
/events/{eventToken}/rsvps/{rsvpToken}:
|
||||||
|
delete:
|
||||||
|
operationId: cancelRsvp
|
||||||
|
summary: Cancel RSVP
|
||||||
|
description: |
|
||||||
|
Permanently deletes an RSVP identified by the RSVP token.
|
||||||
|
Idempotent: returns 204 whether the RSVP existed or not.
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: eventToken
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Event token (UUID)
|
||||||
|
- name: rsvpToken
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: RSVP token (UUID) identifying the attendance to cancel
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: >
|
||||||
|
RSVP successfully cancelled (or was already cancelled).
|
||||||
|
No response body.
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
|
||||||
|
/events/{eventToken}/rsvps:
|
||||||
post:
|
post:
|
||||||
operationId: createRsvp
|
operationId: createRsvp
|
||||||
summary: Submit an RSVP for an event
|
summary: Submit an RSVP for an event
|
||||||
tags:
|
tags:
|
||||||
- events
|
- events
|
||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: eventToken
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
@@ -83,14 +115,55 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
/events/{token}:
|
/events/{eventToken}/attendees:
|
||||||
|
get:
|
||||||
|
operationId: getAttendees
|
||||||
|
summary: Get attendee list for an event (organizer only)
|
||||||
|
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
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Attendee list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GetAttendeesResponse"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
/events/{eventToken}:
|
||||||
get:
|
get:
|
||||||
operationId: getEvent
|
operationId: getEvent
|
||||||
summary: Get public event details by token
|
summary: Get public event details by token
|
||||||
tags:
|
tags:
|
||||||
- events
|
- events
|
||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: eventToken
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
@@ -111,6 +184,58 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
|
patch:
|
||||||
|
operationId: patchEvent
|
||||||
|
summary: Update an event (currently cancel)
|
||||||
|
description: |
|
||||||
|
Partial update of an event resource. Currently the only supported operation
|
||||||
|
is cancellation (setting cancelled to true). Requires the organizer token.
|
||||||
|
Cancellation is irreversible.
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: eventToken
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Public event token
|
||||||
|
- name: organizerToken
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Organizer token for authorization
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PatchEventRequest"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Event updated successfully
|
||||||
|
"403":
|
||||||
|
description: Invalid organizer token
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"404":
|
||||||
|
description: Event not found
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
"409":
|
||||||
|
description: Event is already cancelled
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
CreateEventRequest:
|
CreateEventRequest:
|
||||||
@@ -119,7 +244,6 @@ components:
|
|||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- expiryDate
|
|
||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
@@ -140,11 +264,6 @@ 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
|
||||||
@@ -154,7 +273,6 @@ components:
|
|||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- expiryDate
|
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
type: string
|
type: string
|
||||||
@@ -177,10 +295,6 @@ 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
|
||||||
@@ -190,7 +304,7 @@ components:
|
|||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- attendeeCount
|
- attendeeCount
|
||||||
- expired
|
- cancelled
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
type: string
|
type: string
|
||||||
@@ -223,10 +337,31 @@ components:
|
|||||||
minimum: 0
|
minimum: 0
|
||||||
description: Number of confirmed attendees (attending=true)
|
description: Number of confirmed attendees (attending=true)
|
||||||
example: 12
|
example: 12
|
||||||
expired:
|
cancelled:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the event's expiry date has passed
|
description: Whether the event has been cancelled
|
||||||
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
|
||||||
@@ -256,6 +391,30 @@ components:
|
|||||||
description: Guest's display name as stored
|
description: Guest's display name as stored
|
||||||
example: "Max Mustermann"
|
example: "Max Mustermann"
|
||||||
|
|
||||||
|
GetAttendeesResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- attendees
|
||||||
|
properties:
|
||||||
|
attendees:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Attendee"
|
||||||
|
example:
|
||||||
|
- name: "Alice"
|
||||||
|
- name: "Bob"
|
||||||
|
|
||||||
|
Attendee:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 100
|
||||||
|
example: "Alice"
|
||||||
|
|
||||||
ProblemDetail:
|
ProblemDetail:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
|||||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||||
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
|
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.core.domain.JavaClass;
|
||||||
import com.tngtech.archunit.core.importer.ImportOption;
|
import com.tngtech.archunit.core.importer.ImportOption;
|
||||||
import com.tngtech.archunit.junit.AnalyzeClasses;
|
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||||
import com.tngtech.archunit.junit.ArchTest;
|
import com.tngtech.archunit.junit.ArchTest;
|
||||||
|
import com.tngtech.archunit.lang.ArchCondition;
|
||||||
import com.tngtech.archunit.lang.ArchRule;
|
import com.tngtech.archunit.lang.ArchRule;
|
||||||
|
import com.tngtech.archunit.lang.ConditionEvents;
|
||||||
|
import com.tngtech.archunit.lang.SimpleConditionEvent;
|
||||||
|
|
||||||
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
|
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
|
||||||
class HexagonalArchitectureTest {
|
class HexagonalArchitectureTest {
|
||||||
@@ -65,4 +69,24 @@ class HexagonalArchitectureTest {
|
|||||||
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
|
static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses()
|
||||||
.that().resideInAPackage("de.fete.adapter.in.web..")
|
.that().resideInAPackage("de.fete.adapter.in.web..")
|
||||||
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
|
.should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out..");
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule domainModelsMustBeRecords = classes()
|
||||||
|
.that().resideInAPackage("de.fete.domain.model..")
|
||||||
|
.and().doNotHaveSimpleName("package-info")
|
||||||
|
.should(beRecords());
|
||||||
|
|
||||||
|
private static ArchCondition<JavaClass> beRecords() {
|
||||||
|
return new ArchCondition<>("be records") {
|
||||||
|
@Override
|
||||||
|
public void check(JavaClass javaClass,
|
||||||
|
ConditionEvents events) {
|
||||||
|
boolean isRecord = javaClass.reflect().isRecord();
|
||||||
|
if (!isRecord) {
|
||||||
|
events.add(SimpleConditionEvent.violated(javaClass,
|
||||||
|
javaClass.getFullName() + " is not a record"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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;
|
||||||
@@ -20,6 +22,7 @@ import de.fete.adapter.out.persistence.RsvpJpaRepository;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -55,8 +58,7 @@ 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)
|
||||||
@@ -67,7 +69,6 @@ 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(
|
||||||
@@ -79,7 +80,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(request.getExpiryDate());
|
assertThat(persisted.getExpiryDate()).isEqualTo(LocalDate.of(2026, 6, 22));
|
||||||
assertThat(persisted.getDateTime().toInstant())
|
assertThat(persisted.getDateTime().toInstant())
|
||||||
.isEqualTo(request.getDateTime().toInstant());
|
.isEqualTo(request.getDateTime().toInstant());
|
||||||
assertThat(persisted.getOrganizerToken()).isNotNull();
|
assertThat(persisted.getOrganizerToken()).isNotNull();
|
||||||
@@ -91,8 +92,7 @@ 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,8 +119,7 @@ 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)
|
||||||
@@ -139,26 +138,6 @@ 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")
|
||||||
@@ -171,93 +150,12 @@ 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)
|
||||||
@@ -273,8 +171,7 @@ 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)
|
||||||
@@ -302,7 +199,6 @@ 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,18 +223,6 @@ 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
|
||||||
@@ -439,6 +323,275 @@ class EventControllerIntegrationTest {
|
|||||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GET /events/{token}/attendees tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturnsNamesForOrganizer() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
seedRsvp(event, "Alice");
|
||||||
|
seedRsvp(event, "Bob");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.attendees").isArray())
|
||||||
|
.andExpect(jsonPath("$.attendees.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
|
||||||
|
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Empty Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.attendees").isArray())
|
||||||
|
.andExpect(jsonPath("$.attendees.length()").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Secret Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(
|
||||||
|
"application/problem+json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturns404ForUnknownEvent() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
|
||||||
|
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(
|
||||||
|
"application/problem+json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cancel RSVP tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpReturns204AndDeletesRow() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Cancel Event", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().plusDays(30));
|
||||||
|
UUID rsvpToken = seedRsvpAndGetToken(event, "Departing Guest");
|
||||||
|
|
||||||
|
long countBefore = rsvpJpaRepository.count();
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||||
|
+ "/rsvps/" + rsvpToken))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore - 1);
|
||||||
|
assertThat(rsvpJpaRepository.findByRsvpToken(rsvpToken)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpReturns204WhenAlreadyDeleted() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Idempotent Event", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||||
|
+ "/rsvps/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpReturns204WhenEventNotFound() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/events/" + UUID.randomUUID()
|
||||||
|
+ "/rsvps/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attendeeCountDecreasesAfterCancelRsvp() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Count Cancel Event", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().plusDays(30));
|
||||||
|
UUID rsvpToken = seedRsvpAndGetToken(event, "Leaving Guest");
|
||||||
|
seedRsvp(event, "Staying Guest");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.andExpect(jsonPath("$.attendeeCount").value(2));
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||||
|
+ "/rsvps/" + rsvpToken))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.andExpect(jsonPath("$.attendeeCount").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cancel Event tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventReturns204AndPersists() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Cancel Me", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
var body = Map.of(
|
||||||
|
"cancelled", true,
|
||||||
|
"cancellationReason", "Venue closed");
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + event.getEventToken()
|
||||||
|
+ "?organizerToken=" + event.getOrganizerToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
EventJpaEntity persisted = jpaRepository
|
||||||
|
.findByEventToken(event.getEventToken()).orElseThrow();
|
||||||
|
assertThat(persisted.isCancelled()).isTrue();
|
||||||
|
assertThat(persisted.getCancellationReason()).isEqualTo("Venue closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventWithoutReasonReturns204() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Cancel No Reason", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
var body = Map.of("cancelled", true);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + event.getEventToken()
|
||||||
|
+ "?organizerToken=" + event.getOrganizerToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
EventJpaEntity persisted = jpaRepository
|
||||||
|
.findByEventToken(event.getEventToken()).orElseThrow();
|
||||||
|
assertThat(persisted.isCancelled()).isTrue();
|
||||||
|
assertThat(persisted.getCancellationReason()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventWithWrongOrganizerTokenReturns403() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Wrong Token", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
var body = Map.of("cancelled", true);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + event.getEventToken()
|
||||||
|
+ "?organizerToken=" + UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-organizer-token"));
|
||||||
|
|
||||||
|
assertThat(jpaRepository.findByEventToken(event.getEventToken())
|
||||||
|
.orElseThrow().isCancelled()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventNotFoundReturns404() throws Exception {
|
||||||
|
var body = Map.of("cancelled", true);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + UUID.randomUUID()
|
||||||
|
+ "?organizerToken=" + UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelAlreadyCancelledEventReturns409() throws Exception {
|
||||||
|
EventJpaEntity event = seedCancelledEvent("Already Cancelled");
|
||||||
|
|
||||||
|
var body = Map.of("cancelled", true);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/events/" + event.getEventToken()
|
||||||
|
+ "?organizerToken=" + event.getOrganizerToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(body)))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-already-cancelled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEventReturnsCancelledFields() throws Exception {
|
||||||
|
EventJpaEntity event = seedCancelledEvent("Weather Event");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.cancelled").value(true))
|
||||||
|
.andExpect(jsonPath("$.cancellationReason").value("Cancelled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEventReturnsNotCancelledByDefault() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Active Event", null, "Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.cancelled").value(false))
|
||||||
|
.andExpect(jsonPath("$.cancellationReason").doesNotExist());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpOnCancelledEventReturns409() throws Exception {
|
||||||
|
EventJpaEntity event = seedCancelledEvent("Cancelled RSVP");
|
||||||
|
long countBefore = rsvpJpaRepository.count();
|
||||||
|
|
||||||
|
var request = new CreateRsvpRequest().name("Late Guest");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-cancelled"));
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventJpaEntity seedCancelledEvent(String title) {
|
||||||
|
var entity = new EventJpaEntity();
|
||||||
|
entity.setEventToken(UUID.randomUUID());
|
||||||
|
entity.setOrganizerToken(UUID.randomUUID());
|
||||||
|
entity.setTitle(title);
|
||||||
|
entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||||
|
entity.setTimezone("Europe/Berlin");
|
||||||
|
entity.setExpiryDate(LocalDate.now().plusDays(30));
|
||||||
|
entity.setCreatedAt(OffsetDateTime.now());
|
||||||
|
entity.setCancelled(true);
|
||||||
|
entity.setCancellationReason("Cancelled");
|
||||||
|
return jpaRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID seedRsvpAndGetToken(EventJpaEntity event, String name) {
|
||||||
|
var rsvp = new RsvpJpaEntity();
|
||||||
|
UUID token = UUID.randomUUID();
|
||||||
|
rsvp.setRsvpToken(token);
|
||||||
|
rsvp.setEventId(event.getId());
|
||||||
|
rsvp.setName(name);
|
||||||
|
rsvpJpaRepository.save(rsvp);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedRsvp(EventJpaEntity event, String name) {
|
||||||
|
var rsvp = new RsvpJpaEntity();
|
||||||
|
rsvp.setRsvpToken(UUID.randomUUID());
|
||||||
|
rsvp.setEventId(event.getId());
|
||||||
|
rsvp.setName(name);
|
||||||
|
rsvpJpaRepository.save(rsvp);
|
||||||
|
}
|
||||||
|
|
||||||
private EventJpaEntity seedEvent(
|
private EventJpaEntity seedEvent(
|
||||||
String title, String description, String timezone,
|
String title, String description, String timezone,
|
||||||
String location, LocalDate expiryDate) {
|
String location, LocalDate expiryDate) {
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
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 & Jerry");
|
||||||
|
assertThat(html).contains("& more");
|
||||||
|
assertThat(html).contains("<times>");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import de.fete.TestcontainersConfig;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
|
@Transactional
|
||||||
|
class EventPersistenceAdapterIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EventRepository eventRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredRemovesExpiredEvents() {
|
||||||
|
Event expired = buildEvent("Expired Party", LocalDate.now().minusDays(1));
|
||||||
|
eventRepository.save(expired);
|
||||||
|
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(deleted).isGreaterThanOrEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredKeepsNonExpiredEvents() {
|
||||||
|
Event future = buildEvent("Future Party", LocalDate.now().plusDays(30));
|
||||||
|
Event saved = eventRepository.save(future);
|
||||||
|
|
||||||
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredKeepsEventsExpiringToday() {
|
||||||
|
Event today = buildEvent("Today Party", LocalDate.now());
|
||||||
|
Event saved = eventRepository.save(today);
|
||||||
|
|
||||||
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredReturnsZeroWhenNoneExpired() {
|
||||||
|
// Only save a future event
|
||||||
|
buildEvent("Future Only", LocalDate.now().plusDays(60));
|
||||||
|
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(deleted).isGreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Event buildEvent(String title, LocalDate expiryDate) {
|
||||||
|
return new Event(
|
||||||
|
null,
|
||||||
|
EventToken.generate(),
|
||||||
|
OrganizerToken.generate(),
|
||||||
|
title,
|
||||||
|
"Test description",
|
||||||
|
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||||
|
ZoneId.of("Europe/Berlin"),
|
||||||
|
"Test Location",
|
||||||
|
expiryDate,
|
||||||
|
OffsetDateTime.now(),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,8 @@ class EventPersistenceAdapterTest {
|
|||||||
|
|
||||||
Event saved = eventRepository.save(event);
|
Event saved = eventRepository.save(event);
|
||||||
|
|
||||||
assertThat(saved.getId()).isNotNull();
|
assertThat(saved.id()).isNotNull();
|
||||||
assertThat(saved.getTitle()).isEqualTo("Test Event");
|
assertThat(saved.title()).isEqualTo("Test Event");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -39,11 +39,11 @@ class EventPersistenceAdapterTest {
|
|||||||
Event event = buildEvent();
|
Event event = buildEvent();
|
||||||
Event saved = eventRepository.save(event);
|
Event saved = eventRepository.save(event);
|
||||||
|
|
||||||
Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
|
Optional<Event> found = eventRepository.findByEventToken(saved.eventToken());
|
||||||
|
|
||||||
assertThat(found).isPresent();
|
assertThat(found).isPresent();
|
||||||
assertThat(found.get().getTitle()).isEqualTo("Test Event");
|
assertThat(found.get().title()).isEqualTo("Test Event");
|
||||||
assertThat(found.get().getId()).isEqualTo(saved.getId());
|
assertThat(found.get().id()).isEqualTo(saved.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -61,42 +61,47 @@ class EventPersistenceAdapterTest {
|
|||||||
OffsetDateTime createdAt =
|
OffsetDateTime createdAt =
|
||||||
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
||||||
|
|
||||||
var event = new Event();
|
var event = new Event(
|
||||||
event.setEventToken(EventToken.generate());
|
null,
|
||||||
event.setOrganizerToken(OrganizerToken.generate());
|
EventToken.generate(),
|
||||||
event.setTitle("Full Event");
|
OrganizerToken.generate(),
|
||||||
event.setDescription("A detailed description");
|
"Full Event",
|
||||||
event.setDateTime(dateTime);
|
"A detailed description",
|
||||||
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
dateTime,
|
||||||
event.setLocation("Berlin, Germany");
|
ZoneId.of("Europe/Berlin"),
|
||||||
event.setExpiryDate(expiryDate);
|
"Berlin, Germany",
|
||||||
event.setCreatedAt(createdAt);
|
expiryDate,
|
||||||
|
createdAt,
|
||||||
|
false,
|
||||||
|
null);
|
||||||
|
|
||||||
Event saved = eventRepository.save(event);
|
Event saved = eventRepository.save(event);
|
||||||
Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
|
Event found = eventRepository.findByEventToken(saved.eventToken()).orElseThrow();
|
||||||
|
|
||||||
assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
|
assertThat(found.eventToken()).isEqualTo(event.eventToken());
|
||||||
assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
|
assertThat(found.organizerToken()).isEqualTo(event.organizerToken());
|
||||||
assertThat(found.getTitle()).isEqualTo("Full Event");
|
assertThat(found.title()).isEqualTo("Full Event");
|
||||||
assertThat(found.getDescription()).isEqualTo("A detailed description");
|
assertThat(found.description()).isEqualTo("A detailed description");
|
||||||
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
assertThat(found.dateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
||||||
assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
assertThat(found.timezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
||||||
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
|
assertThat(found.location()).isEqualTo("Berlin, Germany");
|
||||||
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
|
assertThat(found.expiryDate()).isEqualTo(expiryDate);
|
||||||
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
|
assertThat(found.createdAt().toInstant()).isEqualTo(createdAt.toInstant());
|
||||||
}
|
}
|
||||||
|
|
||||||
private Event buildEvent() {
|
private Event buildEvent() {
|
||||||
var event = new Event();
|
return new Event(
|
||||||
event.setEventToken(EventToken.generate());
|
null,
|
||||||
event.setOrganizerToken(OrganizerToken.generate());
|
EventToken.generate(),
|
||||||
event.setTitle("Test Event");
|
OrganizerToken.generate(),
|
||||||
event.setDescription("Test description");
|
"Test Event",
|
||||||
event.setDateTime(OffsetDateTime.now().plusDays(7));
|
"Test description",
|
||||||
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
OffsetDateTime.now().plusDays(7),
|
||||||
event.setLocation("Somewhere");
|
ZoneId.of("Europe/Berlin"),
|
||||||
event.setExpiryDate(LocalDate.now().plusDays(30));
|
"Somewhere",
|
||||||
event.setCreatedAt(OffsetDateTime.now());
|
LocalDate.now().plusDays(30),
|
||||||
return event;
|
OffsetDateTime.now(),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class EventServiceCancelTest {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
|
||||||
|
private static final Instant FIXED_INSTANT =
|
||||||
|
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
|
||||||
|
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EventRepository eventRepository;
|
||||||
|
|
||||||
|
private EventService eventService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
eventService = new EventService(eventRepository, FIXED_CLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventDelegatesToDomainAndSaves() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||||
|
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||||
|
null, false, null);
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
eventService.cancelEvent(eventToken, organizerToken, true, "Venue unavailable");
|
||||||
|
|
||||||
|
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||||
|
verify(eventRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().cancelled()).isTrue();
|
||||||
|
assertThat(captor.getValue().cancellationReason()).isEqualTo("Venue unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventWithNullReason() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||||
|
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||||
|
null, false, null);
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
eventService.cancelEvent(eventToken, organizerToken, true, null);
|
||||||
|
|
||||||
|
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||||
|
verify(eventRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().cancelled()).isTrue();
|
||||||
|
assertThat(captor.getValue().cancellationReason()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventThrows404WhenNotFound() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
|
||||||
|
.isInstanceOf(EventNotFoundException.class);
|
||||||
|
|
||||||
|
verify(eventRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventThrows403WhenWrongOrganizerToken() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken correctToken = OrganizerToken.generate();
|
||||||
|
var event = new Event(null, eventToken, correctToken, null, null, null, null, null, null,
|
||||||
|
null, false, null);
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
final OrganizerToken wrongToken = OrganizerToken.generate();
|
||||||
|
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, wrongToken, true, null))
|
||||||
|
.isInstanceOf(InvalidOrganizerTokenException.class);
|
||||||
|
|
||||||
|
verify(eventRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventThrows409WhenAlreadyCancelled() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||||
|
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||||
|
null, true, null);
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
|
||||||
|
.isInstanceOf(EventAlreadyCancelledException.class);
|
||||||
|
|
||||||
|
verify(eventRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
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;
|
||||||
@@ -53,19 +52,18 @@ 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.getTitle()).isEqualTo("Birthday Party");
|
assertThat(result.title()).isEqualTo("Birthday Party");
|
||||||
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
assertThat(result.description()).isEqualTo("Come celebrate!");
|
||||||
assertThat(result.getTimezone()).isEqualTo(ZONE);
|
assertThat(result.timezone()).isEqualTo(ZONE);
|
||||||
assertThat(result.getLocation()).isEqualTo("Berlin");
|
assertThat(result.location()).isEqualTo("Berlin");
|
||||||
assertThat(result.getEventToken()).isNotNull();
|
assertThat(result.eventToken()).isNotNull();
|
||||||
assertThat(result.getOrganizerToken()).isNotNull();
|
assertThat(result.organizerToken()).isNotNull();
|
||||||
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
|
assertThat(result.createdAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -75,98 +73,30 @@ 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().getTitle()).isEqualTo("Test");
|
assertThat(captor.getValue().title()).isEqualTo("Test");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void expiryDateTodayThrowsException() {
|
void expiryDateIsEventDatePlusSevenDays() {
|
||||||
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,
|
||||||
TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
eventDate.atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
|
||||||
TODAY.plusDays(2)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(2));
|
assertThat(result.expiryDate()).isEqualTo(eventDate.plusDays(7));
|
||||||
}
|
|
||||||
|
|
||||||
@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) ---
|
||||||
@@ -174,16 +104,15 @@ class EventServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByEventTokenReturnsEvent() {
|
void getByEventTokenReturnsEvent() {
|
||||||
EventToken token = EventToken.generate();
|
EventToken token = EventToken.generate();
|
||||||
var event = new Event();
|
var event = new Event(null, token, null, "Found Event", null, null, null, null, null, null,
|
||||||
event.setEventToken(token);
|
false, null);
|
||||||
event.setTitle("Found Event");
|
|
||||||
when(eventRepository.findByEventToken(token))
|
when(eventRepository.findByEventToken(token))
|
||||||
.thenReturn(Optional.of(event));
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
Optional<Event> result = eventService.getByEventToken(token);
|
Optional<Event> result = eventService.getByEventToken(token);
|
||||||
|
|
||||||
assertThat(result).isPresent();
|
assertThat(result).isPresent();
|
||||||
assertThat(result.get().getTitle()).isEqualTo("Found Event");
|
assertThat(result.get().title()).isEqualTo("Found Event");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -207,12 +136,11 @@ 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.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
|
assertThat(result.timezone()).isEqualTo(ZoneId.of("America/New_York"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ 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;
|
||||||
import de.fete.domain.model.Rsvp;
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import de.fete.domain.model.RsvpToken;
|
||||||
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 java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -18,6 +23,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -49,23 +55,23 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpSucceedsForActiveEvent() {
|
void createRsvpSucceedsForActiveEvent() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.save(any(Rsvp.class)))
|
when(rsvpRepository.save(any(Rsvp.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
|
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
|
||||||
|
|
||||||
assertThat(result.getName()).isEqualTo("Max Mustermann");
|
assertThat(result.name()).isEqualTo("Max Mustermann");
|
||||||
assertThat(result.getRsvpToken()).isNotNull();
|
assertThat(result.rsvpToken()).isNotNull();
|
||||||
assertThat(result.getEventId()).isEqualTo(event.getId());
|
assertThat(result.eventId()).isEqualTo(event.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpPersistsViaRepository() {
|
void createRsvpPersistsViaRepository() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.save(any(Rsvp.class)))
|
when(rsvpRepository.save(any(Rsvp.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
@@ -74,8 +80,8 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
|
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
|
||||||
verify(rsvpRepository).save(captor.capture());
|
verify(rsvpRepository).save(captor.capture());
|
||||||
assertThat(captor.getValue().getName()).isEqualTo("Test Guest");
|
assertThat(captor.getValue().name()).isEqualTo("Test Guest");
|
||||||
assertThat(captor.getValue().getEventId()).isEqualTo(event.getId());
|
assertThat(captor.getValue().eventId()).isEqualTo(event.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -89,22 +95,21 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpTrimsName() {
|
void createRsvpTrimsName() {
|
||||||
Event event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
EventToken token = event.getEventToken();
|
EventToken token = event.eventToken();
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
when(rsvpRepository.save(any(Rsvp.class)))
|
when(rsvpRepository.save(any(Rsvp.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
Rsvp result = rsvpService.createRsvp(token, " Max ");
|
Rsvp result = rsvpService.createRsvp(token, " Max ");
|
||||||
|
|
||||||
assertThat(result.getName()).isEqualTo("Max");
|
assertThat(result.name()).isEqualTo("Max");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpThrowsWhenEventExpired() {
|
void createRsvpThrowsWhenEventExpired() {
|
||||||
var event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY.minusDays(1));
|
||||||
event.setExpiryDate(TODAY.minusDays(1));
|
EventToken token = event.eventToken();
|
||||||
EventToken token = event.getEventToken();
|
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
|
||||||
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||||
@@ -113,25 +118,124 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createRsvpThrowsWhenEventExpiresToday() {
|
void createRsvpThrowsWhenEventExpiresToday() {
|
||||||
var event = buildActiveEvent();
|
Event event = buildActiveEvent(TODAY);
|
||||||
event.setExpiryDate(TODAY);
|
EventToken token = event.eventToken();
|
||||||
EventToken token = event.getEventToken();
|
|
||||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
|
||||||
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||||
.isInstanceOf(EventExpiredException.class);
|
.isInstanceOf(EventExpiredException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Event buildActiveEvent() {
|
@Test
|
||||||
var event = new Event();
|
void getAttendeeNamesReturnsNamesInOrder() {
|
||||||
event.setId(1L);
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
event.setEventToken(EventToken.generate());
|
EventToken token = event.eventToken();
|
||||||
event.setOrganizerToken(OrganizerToken.generate());
|
OrganizerToken orgToken = event.organizerToken();
|
||||||
event.setTitle("Test Event");
|
when(eventRepository.findByEventToken(token))
|
||||||
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
.thenReturn(Optional.of(event));
|
||||||
event.setTimezone(ZONE);
|
when(rsvpRepository.findByEventId(event.id()))
|
||||||
event.setExpiryDate(TODAY.plusDays(30));
|
.thenReturn(List.of(
|
||||||
event.setCreatedAt(OffsetDateTime.now());
|
buildRsvp(1L, "Alice"),
|
||||||
return event;
|
buildRsvp(2L, "Bob"),
|
||||||
|
buildRsvp(3L, "Charlie")));
|
||||||
|
|
||||||
|
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||||
|
|
||||||
|
assertThat(names).containsExactly("Alice", "Bob", "Charlie");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
OrganizerToken orgToken = event.organizerToken();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.findByEventId(event.id()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||||
|
|
||||||
|
assertThat(names).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesThrowsWhenEventNotFound() {
|
||||||
|
EventToken token = EventToken.generate();
|
||||||
|
OrganizerToken orgToken = OrganizerToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> rsvpService.getAttendeeNames(token, orgToken))
|
||||||
|
.isInstanceOf(EventNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
OrganizerToken wrongToken = OrganizerToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> rsvpService.getAttendeeNames(token, wrongToken))
|
||||||
|
.isInstanceOf(InvalidOrganizerTokenException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rsvp buildRsvp(Long id, String name) {
|
||||||
|
return new Rsvp(id, RsvpToken.generate(), 1L, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpDeletesWhenEventAndRsvpExist() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
RsvpToken rsvpToken = RsvpToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(true);
|
||||||
|
|
||||||
|
rsvpService.cancelRsvp(token, rsvpToken);
|
||||||
|
|
||||||
|
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpSucceedsWhenRsvpNotFound() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
RsvpToken rsvpToken = RsvpToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(false);
|
||||||
|
|
||||||
|
rsvpService.cancelRsvp(token, rsvpToken);
|
||||||
|
|
||||||
|
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpSucceedsWhenEventNotFound() {
|
||||||
|
EventToken token = EventToken.generate();
|
||||||
|
RsvpToken rsvpToken = RsvpToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
rsvpService.cancelRsvp(token, rsvpToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Event buildActiveEvent(LocalDate expiryDate) {
|
||||||
|
return new Event(
|
||||||
|
1L,
|
||||||
|
EventToken.generate(),
|
||||||
|
OrganizerToken.generate(),
|
||||||
|
"Test Event",
|
||||||
|
null,
|
||||||
|
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||||
|
ZONE,
|
||||||
|
null,
|
||||||
|
expiryDate,
|
||||||
|
OffsetDateTime.now(),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ class WebConfigTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void apiPrefixNotAccessibleWithoutIt() throws Exception {
|
void apiPrefixNotAccessibleWithoutIt() throws Exception {
|
||||||
// /events without /api prefix should not resolve to the API endpoint
|
// /events without /api prefix should not resolve to the REST API endpoint;
|
||||||
mockMvc.perform(get("/events"))
|
// it is served by SpaController as HTML instead
|
||||||
.andExpect(status().isNotFound());
|
mockMvc.perform(get("/events")
|
||||||
|
.accept("text/html"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
backend/src/test/resources/static/index.html
Normal file
13
backend/src/test/resources/static/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
BIN
docs/screenshots/01-create-event.png
Normal file
BIN
docs/screenshots/01-create-event.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
BIN
docs/screenshots/02-event-detail.png
Normal file
BIN
docs/screenshots/02-event-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
BIN
docs/screenshots/03-rsvp.png
Normal file
BIN
docs/screenshots/03-rsvp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const organizerEvent: StoredEvent = {
|
||||||
|
eventToken: 'org-event-aaa',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
dateTime: '2027-06-15T18:00:00Z',
|
||||||
|
organizerToken: 'org-secret-token',
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendeeEvent: StoredEvent = {
|
||||||
|
eventToken: 'att-event-bbb',
|
||||||
|
title: 'Team Meeting',
|
||||||
|
dateTime: '2027-01-10T09:00:00Z',
|
||||||
|
rsvpToken: 'rsvp-token-1',
|
||||||
|
rsvpName: 'Alice',
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US1: Organizer Cancels Event from List', () => {
|
||||||
|
test('T001: organizer taps delete, confirms, event is removed after successful API call', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent, attendeeEvent]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
let patchCalled = false
|
||||||
|
network.use(
|
||||||
|
http.patch('*/api/events/:token', ({ request, params }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (
|
||||||
|
params['token'] === organizerEvent.eventToken &&
|
||||||
|
url.searchParams.get('organizerToken') === organizerEvent.organizerToken
|
||||||
|
) {
|
||||||
|
patchCalled = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'about:blank', title: 'Forbidden', status: 403 },
|
||||||
|
{ status: 403, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
|
||||||
|
// Click delete on organizer event
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog appears with organizer-specific text
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
// Confirm cancellation
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event is removed from list
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
// Other event remains
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
|
||||||
|
expect(patchCalled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T002: organizer confirms cancellation, API fails, event stays in list and error shown', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.patch('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Internal Server Error',
|
||||||
|
status: 500,
|
||||||
|
},
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event stays in list
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T003: organizer confirms cancellation, API returns 409 Conflict, event is silently removed', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.patch('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Conflict',
|
||||||
|
status: 409,
|
||||||
|
detail: 'Event is already cancelled.',
|
||||||
|
},
|
||||||
|
{ status: 409, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// 409 treated as success — event removed
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T004: organizer opens cancel dialog then dismisses (cancel button), event remains', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
// Dismiss via Cancel button
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T004b: organizer opens cancel dialog then dismisses via Escape', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
|
||||||
|
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T004c: organizer opens cancel dialog then dismisses via overlay click', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
// Click on overlay (outside dialog)
|
||||||
|
await page.locator('.confirm-dialog__overlay').click({ position: { x: 10, y: 10 } })
|
||||||
|
|
||||||
|
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US2: Distinct Dialog for Organizer vs. Attendee', () => {
|
||||||
|
test('T011: organizer dialog shows event-cancellation warning', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
const dialog = page.getByRole('alertdialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// Organizer-specific title and message
|
||||||
|
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Cancel event?')
|
||||||
|
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||||
|
'all attendees',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T012: attendee dialog preserves existing RSVP-cancellation message', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([attendeeEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Team Meeting/ }).click()
|
||||||
|
const dialog = page.getByRole('alertdialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// Attendee-specific title and message
|
||||||
|
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Remove event?')
|
||||||
|
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||||
|
'attendance will be cancelled',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
162
frontend/e2e/cancel-event.spec.ts
Normal file
162
frontend/e2e/cancel-event.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 12,
|
||||||
|
cancelled: false,
|
||||||
|
cancellationReason: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizerToken = '550e8400-e29b-41d4-a716-446655440001'
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function organizerSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
organizerToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US1: Organizer cancels event with reason', () => {
|
||||||
|
test('organizer opens cancel bottom sheet, enters reason, confirms — event shows as cancelled on reload', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
let cancelled = false
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return HttpResponse.json({
|
||||||
|
...fullEvent,
|
||||||
|
cancelled: true,
|
||||||
|
cancellationReason: 'Venue closed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
http.patch('*/api/events/:token', ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const token = url.searchParams.get('organizerToken')
|
||||||
|
if (token === organizerToken) {
|
||||||
|
cancelled = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'urn:problem-type:invalid-organizer-token', title: 'Forbidden', status: 403 },
|
||||||
|
{ status: 403 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Cancel button visible for organizer
|
||||||
|
const cancelBtn = page.getByRole('button', { name: /Cancel event/i })
|
||||||
|
await expect(cancelBtn).toBeVisible()
|
||||||
|
|
||||||
|
// Open cancel bottom sheet
|
||||||
|
await cancelBtn.click()
|
||||||
|
|
||||||
|
// Fill in reason
|
||||||
|
const reasonField = page.getByLabel(/reason/i)
|
||||||
|
await expect(reasonField).toBeVisible()
|
||||||
|
await reasonField.fill('Venue closed')
|
||||||
|
|
||||||
|
// Confirm cancellation
|
||||||
|
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
|
||||||
|
|
||||||
|
// Event should show as cancelled
|
||||||
|
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
|
||||||
|
await expect(page.getByText('Venue closed')).toBeVisible()
|
||||||
|
|
||||||
|
// Cancel button should be gone
|
||||||
|
await expect(cancelBtn).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US1: Organizer cancels event without reason', () => {
|
||||||
|
test('organizer cancels without reason — event shows as cancelled', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
let cancelled = false
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return HttpResponse.json({
|
||||||
|
...fullEvent,
|
||||||
|
cancelled: true,
|
||||||
|
cancellationReason: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
http.patch('*/api/events/:token', ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const token = url.searchParams.get('organizerToken')
|
||||||
|
if (token === organizerToken) {
|
||||||
|
cancelled = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
return HttpResponse.json({}, { status: 403 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Cancel event/i }).click()
|
||||||
|
|
||||||
|
// Don't fill in reason, just confirm
|
||||||
|
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
|
||||||
|
|
||||||
|
// Event should show as cancelled without reason text
|
||||||
|
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US1: Cancel API failure', () => {
|
||||||
|
test('cancel API fails — error displayed in bottom sheet, button re-enabled for retry', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.patch('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Internal Server Error',
|
||||||
|
status: 500,
|
||||||
|
detail: 'Something went wrong',
|
||||||
|
},
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Cancel event/i }).click()
|
||||||
|
await page.getByRole('button', { name: /Confirm cancellation/i }).click()
|
||||||
|
|
||||||
|
// Error message in bottom sheet
|
||||||
|
await expect(page.getByText(/Could not cancel event/i)).toBeVisible()
|
||||||
|
|
||||||
|
// Confirm button should be re-enabled
|
||||||
|
await expect(page.getByRole('button', { name: /Confirm cancellation/i })).toBeEnabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsvpSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
rsvpToken,
|
||||||
|
rsvpName: 'Anna',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US1: Cancel RSVP from Event Detail View', () => {
|
||||||
|
test('status bar shows cancel affordance when RSVP\'d', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Status bar visible
|
||||||
|
const statusBar = page.getByRole('button', { name: /You're attending/ })
|
||||||
|
await expect(statusBar).toBeVisible()
|
||||||
|
|
||||||
|
// Cancel button hidden initially
|
||||||
|
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tapping status bar reveals cancel button', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Tap status bar
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
|
||||||
|
// Cancel button appears
|
||||||
|
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirm cancellation → localStorage cleared, count decremented, bar reset', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Expand → Cancel RSVP → Confirm in dialog
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
await page.locator('.rsvp-bar__cancel').click()
|
||||||
|
|
||||||
|
// Confirm dialog
|
||||||
|
await expect(page.getByText('The organizer will no longer see you as attending.')).toBeVisible()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||||
|
|
||||||
|
// Bar resets to CTA state
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||||
|
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||||
|
|
||||||
|
// Attendee count decremented
|
||||||
|
await expect(page.getByText('11 going')).toBeVisible()
|
||||||
|
|
||||||
|
// localStorage cleared
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('fete:events')
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||||
|
expect(event?.rsvpToken).toBeUndefined()
|
||||||
|
expect(event?.rsvpName).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('server error → error message, state unchanged', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Expand → Cancel → Confirm in dialog
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
await page.locator('.rsvp-bar__cancel').click()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
|
||||||
|
|
||||||
|
// Attendee count unchanged
|
||||||
|
await expect(page.getByText('12 going')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('re-RSVP after cancel works', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
http.post('*/api/events/:token/rsvps', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Cancel first
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
await page.locator('.rsvp-bar__cancel').click()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||||
|
|
||||||
|
// CTA should be back
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||||
|
|
||||||
|
// Re-RSVP
|
||||||
|
await page.getByRole('button', { name: "I'm attending" }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||||
|
await dialog.getByLabel('Your name').fill('Max')
|
||||||
|
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||||
|
|
||||||
|
// Status bar returns
|
||||||
|
await expect(page.getByText("You're attending!")).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US2: Auto-Cancel on Event List Removal', () => {
|
||||||
|
test('removal of RSVP\'d event shows attendance warning in dialog', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removal of non-RSVP\'d watcher event shows standard dialog', async ({ page }) => {
|
||||||
|
const watcherEvent: StoredEvent = {
|
||||||
|
eventToken: 'watcher-token',
|
||||||
|
title: 'Watcher Event',
|
||||||
|
dateTime: '2027-06-15T18:00:00Z',
|
||||||
|
}
|
||||||
|
await page.addInitScript(seedEvents([watcherEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Watcher events are removed directly without dialog
|
||||||
|
await page.getByRole('button', { name: /Remove Watcher Event/ }).click()
|
||||||
|
|
||||||
|
// Watcher removal is immediate — event disappears
|
||||||
|
await expect(page.getByText('Watcher Event')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirm removal → DELETE called → event removed from list', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event gone
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
|
||||||
|
// localStorage updated
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('fete:events')
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
const found = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||||
|
expect(found).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('server error on DELETE → error message, event stays in list', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event still in list
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dismiss dialog → no changes', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
|
// Event still there
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
|
||||||
|
test('cancel from detail view with stale token (404) → treated as success', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Cancel flow
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
await page.locator('.rsvp-bar__cancel').click()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||||
|
|
||||||
|
// Treated as success — CTA returns
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||||
|
|
||||||
|
// localStorage cleaned
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('fete:events')
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||||
|
expect(event?.rsvpToken).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('event list removal with stale token (404) → treated as success', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event removed from list
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
frontend/e2e/cancelled-event-visitor.spec.ts
Normal file
74
frontend/e2e/cancelled-event-visitor.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
|
||||||
|
const cancelledEventWithReason = {
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 12,
|
||||||
|
cancelled: true,
|
||||||
|
cancellationReason: 'Venue no longer available',
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelledEventWithoutReason = {
|
||||||
|
...cancelledEventWithReason,
|
||||||
|
cancellationReason: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US2: Visitor sees cancelled event with reason', () => {
|
||||||
|
test('visitor sees red banner with cancellation reason on cancelled event', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${cancelledEventWithReason.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
|
||||||
|
await expect(page.getByText('Venue no longer available')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US2: Visitor sees cancelled event without reason', () => {
|
||||||
|
test('visitor sees red banner without reason when no reason was provided', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithoutReason)),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${cancelledEventWithoutReason.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByText(/This event has been cancelled/i)).toBeVisible()
|
||||||
|
// No reason text shown
|
||||||
|
await expect(page.getByText('Venue no longer available')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US2: RSVP buttons hidden on cancelled event', () => {
|
||||||
|
test('RSVP buttons hidden on cancelled event, other details remain visible', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${cancelledEventWithReason.eventToken}`)
|
||||||
|
|
||||||
|
// Event details are still visible
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Bring your own drinks!')).toBeVisible()
|
||||||
|
await expect(page.getByText('Central Park, NYC')).toBeVisible()
|
||||||
|
await expect(page.getByText('12 going')).toBeVisible()
|
||||||
|
|
||||||
|
// RSVP bar is NOT visible
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,7 +9,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
|
|
||||||
await expect(page.getByText('Title is required.')).toBeVisible()
|
await expect(page.getByText('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 }) => {
|
||||||
@@ -19,7 +18,6 @@ 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()
|
||||||
|
|
||||||
@@ -31,7 +29,6 @@ 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\/.+/)
|
||||||
@@ -59,7 +56,6 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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', () => {
|
||||||
@@ -170,16 +169,4 @@ 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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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', () => {
|
||||||
@@ -52,20 +51,6 @@ test.describe('US-1: View event details', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('US-2: View expired event', () => {
|
|
||||||
test('shows "event has ended" banner for expired event', async ({ page, network }) => {
|
|
||||||
network.use(
|
|
||||||
http.get('*/api/events/:token', () => {
|
|
||||||
return HttpResponse.json({ ...fullEvent, expired: true })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
|
||||||
|
|
||||||
await expect(page.getByText('This event has ended.')).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe('US-4: Event not found', () => {
|
test.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(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ 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',
|
||||||
}
|
}
|
||||||
@@ -24,7 +22,6 @@ 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 {
|
||||||
@@ -85,7 +82,6 @@ test.describe('US4: Past Events Appear Faded', () => {
|
|||||||
location: '',
|
location: '',
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
attendeeCount: 0,
|
attendeeCount: 0,
|
||||||
expired: true,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -97,19 +93,30 @@ 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 ({ page }) => {
|
test('delete icon triggers confirmation dialog, confirm removes event', async ({
|
||||||
|
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
|
// Click delete on Summer BBQ (organizer event)
|
||||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
|
||||||
// Confirmation dialog appears
|
// Confirmation dialog appears (organizer event shows "Cancel event?")
|
||||||
await expect(page.getByText('Remove event?')).toBeVisible()
|
await expect(page.getByText('Cancel event?')).toBeVisible()
|
||||||
|
|
||||||
// Confirm removal
|
// Confirm removal
|
||||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
@@ -124,13 +131,13 @@ test.describe('US3: Remove Event from List', () => {
|
|||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
await expect(page.getByText('Remove event?')).toBeVisible()
|
await expect(page.getByText('Cancel 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('Remove event?')).not.toBeVisible()
|
await expect(page.getByText('Cancel event?')).not.toBeVisible()
|
||||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -143,7 +150,7 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
|
|||||||
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
|
const 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('Organizer')
|
await expect(badge).toHaveText('Organizing')
|
||||||
await expect(badge).toHaveClass(/event-card__badge--organizer/)
|
await expect(badge).toHaveClass(/event-card__badge--organizer/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -154,16 +161,19 @@ test.describe('US5: Visual Distinction for Event Roles', () => {
|
|||||||
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
|
const 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('Attendee')
|
await expect(badge).toHaveText('Attending')
|
||||||
await expect(badge).toHaveClass(/event-card__badge--attendee/)
|
await expect(badge).toHaveClass(/event-card__badge--attendee/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows no badge for events without organizerToken or rsvpToken', async ({ page }) => {
|
test('shows watcher badge for events without organizerToken or rsvpToken', async ({ page }) => {
|
||||||
await page.addInitScript(seedEvents([pastEvent]))
|
await page.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' })
|
||||||
await expect(card.locator('.event-card__badge')).toHaveCount(0)
|
const badge = card.locator('.event-card__badge')
|
||||||
|
await expect(badge).toBeVisible()
|
||||||
|
await expect(badge).toHaveText('Watching')
|
||||||
|
await expect(badge).toHaveClass(/event-card__badge--watcher/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -199,13 +209,11 @@ test.describe('Temporal Grouping: Section Headers', () => {
|
|||||||
eventToken: 'today-1',
|
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('/')
|
||||||
@@ -245,7 +253,6 @@ 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('/')
|
||||||
@@ -262,7 +269,6 @@ 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('/')
|
||||||
@@ -355,7 +361,6 @@ test.describe('US1: View My Events', () => {
|
|||||||
location: '',
|
location: '',
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
attendeeCount: 0,
|
attendeeCount: 0,
|
||||||
expired: false,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
|
||||||
|
const eventToken = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||||
|
const organizerToken = 'f9e8d7c6-b5a4-3210-fedc-ba9876543210'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken,
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 3,
|
||||||
|
expired: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendeesResponse = {
|
||||||
|
attendees: [
|
||||||
|
{ name: 'Alice' },
|
||||||
|
{ name: 'Bob' },
|
||||||
|
{ name: 'Charlie' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US-1: View attendee list as organizer', () => {
|
||||||
|
test('organizer sees attendee names', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
http.get('*/api/events/:token/attendees', () => {
|
||||||
|
return HttpResponse.json(attendeesResponse)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set organizer token in localStorage before navigating
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate(
|
||||||
|
([et, ot]) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'fete:events',
|
||||||
|
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[eventToken, organizerToken],
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('3 Attendees')).toBeVisible()
|
||||||
|
await expect(page.getByText('Alice')).toBeVisible()
|
||||||
|
await expect(page.getByText('Bob')).toBeVisible()
|
||||||
|
await expect(page.getByText('Charlie')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('visitor does not see attendee list', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('3 going')).toBeVisible()
|
||||||
|
await expect(page.locator('.attendee-list')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('organizer sees empty state when no attendees', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({ ...fullEvent, attendeeCount: 0 })
|
||||||
|
}),
|
||||||
|
http.get('*/api/events/:token/attendees', () => {
|
||||||
|
return HttpResponse.json({ attendees: [] })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate(
|
||||||
|
([et, ot]) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'fete:events',
|
||||||
|
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[eventToken, organizerToken],
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('0 Attendees')).toBeVisible()
|
||||||
|
await expect(page.getByText('No attendees yet.')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
218
frontend/e2e/watch-event.spec.ts
Normal file
218
frontend/e2e/watch-event.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 12,
|
||||||
|
cancelled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
|
||||||
|
const organizerToken = 'org-token-1234'
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsvpSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
rsvpToken,
|
||||||
|
rsvpName: 'Anna',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function organizerSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
organizerToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US1: Watch event from detail page', () => {
|
||||||
|
test('bookmark unfilled by default, tapping watches the event', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
||||||
|
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.locator('.rsvp-bar__bookmark-inner')
|
||||||
|
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.locator('.rsvp-bar__bookmark-inner')
|
||||||
|
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.locator('.rsvp-bar__bookmark-inner')
|
||||||
|
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.locator('.rsvp-bar__bookmark-inner')
|
||||||
|
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.locator('.rsvp-bar__bookmark-inner')
|
||||||
|
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.locator('.rsvp-bar__bookmark-inner')
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
<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>
|
||||||
|
|||||||
1240
frontend/package-lock.json
generated
1240
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,21 +38,27 @@
|
|||||||
"@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.51.0",
|
"eslint-plugin-oxlint": "~1.55.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"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.51.0",
|
"oxlint": "~1.55.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-vue-devtools": "^8.0.6",
|
"vite-plugin-vue-devtools": "^8.0.6",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"vue-tsc": "^3.2.5"
|
"vue-tsc": "^3.2.5"
|
||||||
},
|
},
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
3
frontend/public/favicon.svg
Normal file
3
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 144 B |
BIN
frontend/public/og-image.png
Normal file
BIN
frontend/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -1,9 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
|
<header v-if="route.name !== 'home'" class="app-header">
|
||||||
|
<BackLink />
|
||||||
|
</header>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
|
import BackLink from '@/components/BackLink.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -16,6 +16,37 @@
|
|||||||
--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%);
|
||||||
@@ -33,7 +64,7 @@
|
|||||||
--radius-button: 14px;
|
--radius-button: 14px;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.12);
|
||||||
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
|
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
@@ -60,7 +91,22 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--gradient-primary);
|
background-color: var(--color-dark-base);
|
||||||
|
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 {
|
||||||
@@ -82,38 +128,66 @@ body {
|
|||||||
/* Card-style form fields */
|
/* Card-style form fields */
|
||||||
.form-field {
|
.form-field {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border: none;
|
border: 1px solid #e0e0e0;
|
||||||
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: box-shadow 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field.glass {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field:focus {
|
.form-field:focus {
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
border-color: var(--color-glass-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field::placeholder {
|
.form-field::placeholder {
|
||||||
color: #999;
|
color: var(--color-text-muted);
|
||||||
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 {
|
||||||
@@ -128,22 +202,29 @@ textarea.form-field {
|
|||||||
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-accent);
|
background: var(--color-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: none;
|
border: 1px solid #e0e0e0;
|
||||||
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;
|
||||||
box-shadow: var(--shadow-button);
|
transition: border-color 0.2s ease, transform 0.1s ease;
|
||||||
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 {
|
||||||
opacity: 0.92;
|
border-color: var(--color-glass-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:active {
|
.btn-primary:active {
|
||||||
@@ -157,7 +238,7 @@ textarea.form-field {
|
|||||||
|
|
||||||
/* Error message */
|
/* Error message */
|
||||||
.field-error {
|
.field-error {
|
||||||
color: #fff;
|
color: var(--color-danger-solid);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding-left: 0.25rem;
|
padding-left: 0.25rem;
|
||||||
@@ -176,6 +257,66 @@ textarea.form-field {
|
|||||||
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; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Utility */
|
/* Utility */
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -197,7 +338,7 @@ textarea.form-field {
|
|||||||
.sheet-title {
|
.sheet-title {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text);
|
color: var(--color-text-on-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsvp-form {
|
.rsvp-form {
|
||||||
@@ -206,15 +347,16 @@ textarea.form-field {
|
|||||||
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);
|
color: var(--color-text-on-gradient);
|
||||||
padding-left: 0.25rem;
|
padding-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsvp-form__field-error {
|
.rsvp-form__field-error {
|
||||||
color: #d32f2f;
|
color: var(--color-danger-solid);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding-left: 0.25rem;
|
padding-left: 0.25rem;
|
||||||
|
|||||||
59
frontend/src/components/AttendeeList.vue
Normal file
59
frontend/src/components/AttendeeList.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<section class="attendee-list">
|
||||||
|
<h3 class="attendee-list__heading">
|
||||||
|
{{ attendees.length === 1 ? '1 Attendee' : `${attendees.length} Attendees` }}
|
||||||
|
</h3>
|
||||||
|
<ul v-if="attendees.length > 0" class="attendee-list__items">
|
||||||
|
<li v-for="(name, index) in attendees" :key="index" class="attendee-list__item">
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="attendee-list__empty">No attendees yet.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
attendees: string[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.attendee-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__heading {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__items {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__item {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__empty {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
frontend/src/components/BackLink.vue
Normal file
28
frontend/src/components/BackLink.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<RouterLink to="/" class="back-link" aria-label="Back to home">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
<span class="back-link__brand">fete</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link__brand {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,18 @@
|
|||||||
<Teleport to="body">
|
<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 class="sheet" role="dialog" aria-modal="true" :aria-label="label" ref="sheetEl" tabindex="-1">
|
<div
|
||||||
|
class="sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="label"
|
||||||
|
ref="sheetEl"
|
||||||
|
tabindex="-1"
|
||||||
|
:style="dragStyle"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
<div class="sheet__handle" aria-hidden="true" />
|
<div class="sheet__handle" aria-hidden="true" />
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
@@ -12,14 +23,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
label: string
|
label: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -39,13 +50,52 @@ 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: rgba(0, 0, 0, 0.4);
|
background: var(--color-glass-overlay);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -53,7 +103,10 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sheet {
|
.sheet {
|
||||||
background: var(--color-card);
|
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||||
|
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%;
|
||||||
@@ -67,7 +120,7 @@ watch(
|
|||||||
.sheet__handle {
|
.sheet__handle {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: #ccc;
|
background: var(--color-glass-border-hover);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ watch(
|
|||||||
.confirm-dialog__overlay {
|
.confirm-dialog__overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: var(--color-glass-overlay);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -84,9 +84,11 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-dialog {
|
.confirm-dialog {
|
||||||
background: var(--color-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(24px);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -98,13 +100,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);
|
color: var(--color-text-on-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-dialog__message {
|
.confirm-dialog__message {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #666;
|
color: var(--color-text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-dialog__actions {
|
.confirm-dialog__actions {
|
||||||
@@ -130,13 +132,14 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-dialog__btn--cancel {
|
.confirm-dialog__btn--cancel {
|
||||||
background: #e8e8e8;
|
background: var(--color-glass);
|
||||||
color: #555;
|
border: 1px solid var(--color-glass-border);
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-dialog__btn--confirm {
|
.confirm-dialog__btn--confirm {
|
||||||
background: #d32f2f;
|
background: var(--color-danger-solid);
|
||||||
color: #fff;
|
color: var(--color-danger-solid-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-dialog-enter-active,
|
.confirm-dialog-enter-active,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterLink to="/create" class="fab" aria-label="Create event">
|
<RouterLink to="/create" class="fab glow-border" 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>
|
||||||
|
|
||||||
@@ -16,20 +18,26 @@ import { RouterLink } from 'vue-router'
|
|||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-accent);
|
color: var(--color-text-on-gradient);
|
||||||
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, box-shadow 0.15s ease;
|
transition: transform 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 {
|
||||||
@@ -41,6 +49,7 @@ 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;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ defineProps<{
|
|||||||
.date-subheader {
|
.date-subheader {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: var(--color-text-soft);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--spacing-xs) 0;
|
padding: var(--spacing-xs) 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<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="btn-primary empty-state__cta">+ Create Event</RouterLink>
|
<RouterLink to="/create" class="empty-state__cta glow-border glow-border--animated">
|
||||||
|
<span class="empty-state__cta-inner glass-inner">Create Event</span>
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -27,5 +29,34 @@ 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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="event-card"
|
class="event-card glass"
|
||||||
: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' ? 'Organizer' : 'Attendee' }}
|
{{ eventRole === 'organizer' ? 'Organizing' : eventRole === 'attendee' ? 'Attending' : 'Watching' }}
|
||||||
</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'
|
eventRole?: 'organizer' | 'attendee' | 'watcher'
|
||||||
timeDisplayMode?: 'clock' | 'relative'
|
timeDisplayMode?: 'clock' | 'relative'
|
||||||
dateTime?: string
|
dateTime?: string
|
||||||
}>()
|
}>()
|
||||||
@@ -93,11 +93,10 @@ 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 {
|
||||||
@@ -122,7 +121,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);
|
color: var(--color-text-on-gradient);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -131,7 +130,7 @@ function onTouchEnd() {
|
|||||||
.event-card__time {
|
.event-card__time {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #888;
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card__badge {
|
.event-card__badge {
|
||||||
@@ -145,12 +144,18 @@ function onTouchEnd() {
|
|||||||
|
|
||||||
.event-card__badge--organizer {
|
.event-card__badge--organizer {
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: #fff;
|
color: var(--color-text-on-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card__badge--attendee {
|
.event-card__badge--attendee {
|
||||||
background: #e0e0e0;
|
background: var(--color-glass-strong);
|
||||||
color: #555;
|
color: var(--color-text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge--watcher {
|
||||||
|
background: var(--color-glass);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card__delete {
|
.event-card__delete {
|
||||||
@@ -163,14 +168,14 @@ function onTouchEnd() {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: #bbb;
|
color: var(--color-text-muted);
|
||||||
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: #d32f2f;
|
color: var(--color-danger-solid);
|
||||||
background: rgba(211, 47, 47, 0.08);
|
background: rgba(211, 47, 47, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
</section>
|
</section>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="!!pendingDeleteToken"
|
:open="!!pendingDeleteToken"
|
||||||
title="Remove event?"
|
:title="deleteDialogTitle"
|
||||||
message="This event will be removed from your list."
|
:message="deleteDialogMessage"
|
||||||
confirm-label="Remove"
|
confirm-label="Remove"
|
||||||
cancel-label="Cancel"
|
cancel-label="Cancel"
|
||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
@@ -42,24 +42,106 @@ 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, removeEvent } = useEventStorage()
|
const { getStoredEvents, getRsvp, getOrganizerToken, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (pendingDeleteToken.value) {
|
if (!pendingDeleteToken.value) return
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +149,10 @@ function cancelDelete() {
|
|||||||
pendingDeleteToken.value = null
|
pendingDeleteToken.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
|
function getRole(event: StoredEvent): 'organizer' | 'attendee' | 'watcher' {
|
||||||
if (event.organizerToken) return 'organizer'
|
if (event.organizerToken) return 'organizer'
|
||||||
if (event.rsvpToken) return 'attendee'
|
if (event.rsvpToken) return 'attendee'
|
||||||
return undefined
|
return 'watcher'
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupedSections = computed(() => {
|
const groupedSections = computed(() => {
|
||||||
|
|||||||
@@ -2,27 +2,90 @@
|
|||||||
<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">
|
<div v-if="hasRsvp" class="rsvp-bar__status-wrapper">
|
||||||
|
<div
|
||||||
|
class="rsvp-bar__status"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-expanded="expanded"
|
||||||
|
aria-label="You're attending. Tap to show cancel option."
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
@keydown.enter.prevent="expanded = !expanded"
|
||||||
|
@keydown.space.prevent="expanded = !expanded"
|
||||||
|
@keydown.escape="expanded = false"
|
||||||
|
>
|
||||||
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||||
<span class="rsvp-bar__text">You're attending!</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>
|
||||||
|
<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 -->
|
||||||
<button v-else class="btn-primary rsvp-bar__cta" type="button" @click="$emit('open')">
|
<div v-else class="rsvp-bar__row">
|
||||||
I'm attending
|
<div class="rsvp-bar__cta glow-border glow-border--animated">
|
||||||
|
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
|
||||||
|
I'm attending!
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rsvp-bar__bookmark glow-border glow-border--animated">
|
||||||
|
<button
|
||||||
|
class="rsvp-bar__bookmark-inner 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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
hasRsvp?: boolean
|
hasRsvp?: boolean
|
||||||
|
bookmarked?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
open: []
|
open: []
|
||||||
|
cancel: []
|
||||||
|
bookmark: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -43,8 +106,44 @@ defineEmits<{
|
|||||||
max-width: var(--content-max-width);
|
max-width: var(--content-max-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.rsvp-bar__cta {
|
.rsvp-bar__cta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__cta:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__cta:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__cta-inner {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: calc(var(--radius-button) - 2px);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
text-align: center;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__status-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsvp-bar__status {
|
.rsvp-bar__status {
|
||||||
@@ -52,13 +151,22 @@ defineEmits<{
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
background: var(--color-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);
|
||||||
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);
|
color: var(--color-text-on-gradient);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__status:hover {
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsvp-bar__check {
|
.rsvp-bar__check {
|
||||||
@@ -72,4 +180,81 @@ defineEmits<{
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__bookmark {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__bookmark:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__bookmark:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__bookmark-inner {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__bookmark-inner svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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);
|
color: var(--color-text-on-gradient);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--spacing-sm) 0;
|
padding: var(--spacing-sm) 0;
|
||||||
}
|
}
|
||||||
|
|||||||
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import AttendeeList from '../AttendeeList.vue'
|
||||||
|
|
||||||
|
describe('AttendeeList', () => {
|
||||||
|
it('renders attendee names as list items', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: ['Alice', 'Bob', 'Charlie'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = wrapper.findAll('.attendee-list__item')
|
||||||
|
expect(items).toHaveLength(3)
|
||||||
|
expect(items[0]!.text()).toBe('Alice')
|
||||||
|
expect(items[1]!.text()).toBe('Bob')
|
||||||
|
expect(items[2]!.text()).toBe('Charlie')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state message when no attendees', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__empty').text()).toBe('No attendees yet.')
|
||||||
|
expect(wrapper.find('.attendee-list__items').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows plural count heading for multiple attendees', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('5 Attendees')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows singular count heading for one attendee', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: ['Alice'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('1 Attendee')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows zero count heading for no attendees', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('0 Attendees')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -55,12 +55,18 @@ 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('Organizer')
|
expect(wrapper.text()).toContain('Organizing')
|
||||||
})
|
})
|
||||||
|
|
||||||
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('Attendee')
|
expect(wrapper.text()).toContain('Attending')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders watcher badge when eventRole is watcher', () => {
|
||||||
|
const wrapper = mountCard({ eventRole: 'watcher' })
|
||||||
|
expect(wrapper.find('.event-card__badge--watcher').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('Watching')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders no badge when eventRole is undefined', () => {
|
it('renders no badge when eventRole is undefined', () => {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
import { 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: [
|
||||||
@@ -15,13 +22,17 @@ 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', expiryDate: '' },
|
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00' },
|
||||||
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00', expiryDate: '' },
|
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00' },
|
||||||
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00', expiryDate: '' },
|
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00' },
|
||||||
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00', expiryDate: '' },
|
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00' },
|
||||||
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', expiryDate: '' },
|
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00' },
|
||||||
|
{ eventToken: 'org-1', title: 'Organized Event', dateTime: '2026-03-11T19:00:00', organizerToken: 'org-token' },
|
||||||
|
{ eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const removeEventMock = vi.fn()
|
||||||
|
|
||||||
vi.mock('../../composables/useEventStorage', () => ({
|
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
|
||||||
@@ -32,7 +43,21 @@ vi.mock('../../composables/useEventStorage', () => ({
|
|||||||
},
|
},
|
||||||
useEventStorage: () => ({
|
useEventStorage: () => ({
|
||||||
getStoredEvents: () => mockEvents,
|
getStoredEvents: () => mockEvents,
|
||||||
removeEvent: vi.fn(),
|
getRsvp: (token: string) => {
|
||||||
|
const evt = mockEvents.find((e) => e.eventToken === token)
|
||||||
|
if (evt && 'rsvpToken' in evt && 'rsvpName' in evt) {
|
||||||
|
return { rsvpToken: evt.rsvpToken, rsvpName: evt.rsvpName }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
getOrganizerToken: (token: string) => {
|
||||||
|
const evt = mockEvents.find((e) => e.eventToken === token)
|
||||||
|
if (evt && 'organizerToken' in evt) {
|
||||||
|
return (evt as Record<string, unknown>).organizerToken as string
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
removeEvent: removeEventMock,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -40,7 +65,9 @@ vi.mock('../../composables/useRelativeTime', () => ({
|
|||||||
formatRelativeTime: (dateTime: string) => {
|
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-11')) return 'in 6 hours'
|
if (dateTime.includes('03-11T18')) return 'in 6 hours'
|
||||||
|
if (dateTime.includes('03-11T19')) return 'in 7 hours'
|
||||||
|
if (dateTime.includes('03-11T20')) return 'in 8 hours'
|
||||||
if (dateTime.includes('03-13')) return 'in 2 days'
|
if (dateTime.includes('03-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'
|
||||||
@@ -49,7 +76,10 @@ vi.mock('../../composables/useRelativeTime', () => ({
|
|||||||
|
|
||||||
function mountList() {
|
function mountList() {
|
||||||
return mount(EventList, {
|
return mount(EventList, {
|
||||||
global: { plugins: [router] },
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
stubs: { Teleport: true },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +119,7 @@ describe('EventList', () => {
|
|||||||
it('renders all valid events as cards', () => {
|
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(5)
|
expect(cards).toHaveLength(7)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks past events with isPast class', () => {
|
it('marks past events with isPast class', () => {
|
||||||
@@ -137,4 +167,140 @@ 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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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('.rsvp-bar__cta').exists()).toBe(true)
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
||||||
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
|
expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending!")
|
||||||
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
|
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ describe('RsvpBar', () => {
|
|||||||
|
|
||||||
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('.rsvp-bar__cta').trigger('click')
|
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||||
expect(wrapper.emitted('open')).toHaveLength(1)
|
expect(wrapper.emitted('open')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ 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()
|
||||||
@@ -61,7 +60,6 @@ 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')
|
||||||
@@ -79,14 +77,12 @@ 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()
|
||||||
@@ -102,14 +98,12 @@ 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()
|
||||||
@@ -124,7 +118,6 @@ 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')
|
||||||
@@ -154,7 +147,6 @@ 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()
|
||||||
@@ -172,14 +164,12 @@ 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')
|
||||||
@@ -196,7 +186,6 @@ 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')
|
||||||
@@ -205,6 +194,71 @@ describe('useEventStorage', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useEventStorage – saveWatch / isStored', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearStorage()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves a watch-only event (no rsvpToken, no organizerToken)', () => {
|
||||||
|
const { saveWatch, getStoredEvents } = useEventStorage()
|
||||||
|
|
||||||
|
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
|
||||||
|
|
||||||
|
const events = getStoredEvents()
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]!.eventToken).toBe('watch-1')
|
||||||
|
expect(events[0]!.title).toBe('Concert')
|
||||||
|
expect(events[0]!.dateTime).toBe('2026-07-01T20:00:00+02:00')
|
||||||
|
expect(events[0]!.rsvpToken).toBeUndefined()
|
||||||
|
expect(events[0]!.organizerToken).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not duplicate if event already stored', () => {
|
||||||
|
const { saveWatch, saveRsvp, getStoredEvents } = useEventStorage()
|
||||||
|
|
||||||
|
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
|
||||||
|
saveWatch('evt-1', 'Party', '2026-07-01T20:00:00+02:00')
|
||||||
|
|
||||||
|
expect(getStoredEvents()).toHaveLength(1)
|
||||||
|
expect(getStoredEvents()[0]!.rsvpToken).toBe('rsvp-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isStored returns true for watched events', () => {
|
||||||
|
const { saveWatch, isStored } = useEventStorage()
|
||||||
|
|
||||||
|
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
|
||||||
|
|
||||||
|
expect(isStored('watch-1')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isStored returns true for attended events', () => {
|
||||||
|
const { saveRsvp, isStored } = useEventStorage()
|
||||||
|
|
||||||
|
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
|
||||||
|
|
||||||
|
expect(isStored('evt-1')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isStored returns true for organized events', () => {
|
||||||
|
const { saveCreatedEvent, isStored } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'evt-1',
|
||||||
|
organizerToken: 'org-1',
|
||||||
|
title: 'My Event',
|
||||||
|
dateTime: '2026-07-01T20:00:00+02:00',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(isStored('evt-1')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isStored returns false for unknown tokens', () => {
|
||||||
|
const { isStored } = useEventStorage()
|
||||||
|
|
||||||
|
expect(isStored('unknown')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('isValidStoredEvent', () => {
|
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
|
||||||
@@ -220,7 +274,6 @@ 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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -66,7 +65,7 @@ export function useEventStorage() {
|
|||||||
existing.rsvpToken = rsvpToken
|
existing.rsvpToken = rsvpToken
|
||||||
existing.rsvpName = rsvpName
|
existing.rsvpName = rsvpName
|
||||||
} else {
|
} else {
|
||||||
events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName })
|
events.push({ eventToken, title, dateTime, rsvpToken, rsvpName })
|
||||||
}
|
}
|
||||||
writeEvents(events)
|
writeEvents(events)
|
||||||
}
|
}
|
||||||
@@ -79,10 +78,34 @@ 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, removeEvent }
|
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, saveWatch, isStored, removeEvent }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="create">
|
<main class="create">
|
||||||
<header class="create__header">
|
<h1 class="create__title">Great, a Party!</h1>
|
||||||
<RouterLink to="/" class="create__back" aria-label="Back to home">←</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">
|
||||||
@@ -12,7 +9,7 @@
|
|||||||
id="title"
|
id="title"
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-field"
|
class="form-field glass"
|
||||||
required
|
required
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
placeholder="What's the event?"
|
placeholder="What's the event?"
|
||||||
@@ -27,7 +24,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
class="form-field"
|
class="form-field glass"
|
||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
placeholder="Tell people more about it…"
|
placeholder="Tell people more about it…"
|
||||||
:aria-invalid="!!errors.description"
|
:aria-invalid="!!errors.description"
|
||||||
@@ -42,7 +39,7 @@
|
|||||||
id="dateTime"
|
id="dateTime"
|
||||||
v-model="form.dateTime"
|
v-model="form.dateTime"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="form-field"
|
class="form-field glass"
|
||||||
required
|
required
|
||||||
:aria-invalid="!!errors.dateTime"
|
:aria-invalid="!!errors.dateTime"
|
||||||
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
|
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
|
||||||
@@ -56,7 +53,7 @@
|
|||||||
id="location"
|
id="location"
|
||||||
v-model="form.location"
|
v-model="form.location"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-field"
|
class="form-field glass"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
placeholder="Where is it?"
|
placeholder="Where is it?"
|
||||||
:aria-invalid="!!errors.location"
|
:aria-invalid="!!errors.location"
|
||||||
@@ -65,22 +62,7 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<button type="submit" class="btn-primary glass" :disabled="submitting">
|
||||||
<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>
|
||||||
|
|
||||||
@@ -90,8 +72,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, computed, watch } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import { useEventStorage } from '@/composables/useEventStorage'
|
import { useEventStorage } from '@/composables/useEventStorage'
|
||||||
|
|
||||||
@@ -103,7 +85,6 @@ const form = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
dateTime: '',
|
dateTime: '',
|
||||||
location: '',
|
location: '',
|
||||||
expiryDate: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = reactive({
|
const errors = reactive({
|
||||||
@@ -111,31 +92,22 @@ 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 = '' })
|
||||||
|
|
||||||
@@ -153,14 +125,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +150,6 @@ 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,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,7 +175,6 @@ 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 } })
|
||||||
@@ -229,20 +191,7 @@ async function handleSubmit() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
padding-top: var(--spacing-lg);
|
padding-top: calc(var(--spacing-lg) + 2.5rem);
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
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">←</RouterLink>
|
|
||||||
<span class="detail__brand">fete</span>
|
|
||||||
</header>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail__body">
|
<div class="detail__body">
|
||||||
@@ -25,8 +21,10 @@
|
|||||||
|
|
||||||
<!-- Loaded state -->
|
<!-- Loaded state -->
|
||||||
<div v-else-if="state === 'loaded' && event" class="detail__content">
|
<div v-else-if="state === 'loaded' && event" class="detail__content">
|
||||||
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
<!-- Cancellation banner -->
|
||||||
This event has ended.
|
<div v-if="event.cancelled" class="detail__cancelled-banner" role="alert">
|
||||||
|
<p class="detail__cancelled-banner-title">This event has been cancelled</p>
|
||||||
|
<p v-if="event.cancellationReason" class="detail__cancelled-banner-reason">{{ event.cancellationReason }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="detail__title">{{ event.title }}</h1>
|
<h1 class="detail__title">{{ event.title }}</h1>
|
||||||
@@ -54,6 +52,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
|
||||||
|
|
||||||
<div v-if="event.description" class="detail__section">
|
<div v-if="event.description" class="detail__section">
|
||||||
<h2 class="detail__section-title">About</h2>
|
<h2 class="detail__section-title">About</h2>
|
||||||
<p class="detail__description">{{ event.description }}</p>
|
<p class="detail__description">{{ event.description }}</p>
|
||||||
@@ -68,15 +68,69 @@
|
|||||||
<!-- 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" type="button" @click="fetchEvent">Retry</button>
|
<button class="btn-primary glass" type="button" @click="fetchEvent">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RSVP bar (only for loaded, non-expired events) -->
|
<!-- Cancel event button (organizer only, not already cancelled) -->
|
||||||
|
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__cancel-event">
|
||||||
|
<button class="detail__cancel-event-btn" type="button" @click="cancelSheetOpen = true">
|
||||||
|
Cancel event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancel event bottom sheet -->
|
||||||
|
<BottomSheet :open="cancelSheetOpen" label="Cancel event" @close="cancelSheetOpen = false">
|
||||||
|
<h2 class="sheet-title">Cancel event</h2>
|
||||||
|
<form class="cancel-form" @submit.prevent="handleCancelEvent" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="cancel-form__label" for="cancel-reason">Reason (optional)</label>
|
||||||
|
<textarea
|
||||||
|
id="cancel-reason"
|
||||||
|
v-model.trim="cancelReasonInput"
|
||||||
|
class="form-field glass cancel-form__textarea"
|
||||||
|
placeholder="e.g. Venue no longer available"
|
||||||
|
maxlength="2000"
|
||||||
|
rows="3"
|
||||||
|
@input="cancelEventError = ''"
|
||||||
|
/>
|
||||||
|
<span class="cancel-form__counter">{{ cancelReasonInput.length }} / 2000</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="cancel-form__confirm glass-inner"
|
||||||
|
type="submit"
|
||||||
|
:disabled="cancellingEvent"
|
||||||
|
>
|
||||||
|
{{ cancellingEvent ? 'Cancelling…' : 'Confirm cancellation' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="cancelEventError" class="cancel-form__error" role="alert">{{ cancelEventError }}</p>
|
||||||
|
</form>
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
|
<!-- Cancel RSVP error message -->
|
||||||
|
<div v-if="cancelError" class="detail__cancel-error" role="alert">
|
||||||
|
<p>{{ cancelError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RSVP bar (hidden when cancelled) -->
|
||||||
<RsvpBar
|
<RsvpBar
|
||||||
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
|
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled"
|
||||||
:has-rsvp="!!rsvpName"
|
:has-rsvp="!!rsvpName"
|
||||||
|
:bookmarked="eventIsStored"
|
||||||
@open="sheetOpen = true"
|
@open="sheetOpen = true"
|
||||||
|
@cancel="confirmCancelOpen = true"
|
||||||
|
@bookmark="handleBookmarkClick"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
@@ -88,7 +142,7 @@
|
|||||||
<input
|
<input
|
||||||
id="rsvp-name"
|
id="rsvp-name"
|
||||||
v-model.trim="nameInput"
|
v-model.trim="nameInput"
|
||||||
class="form-field"
|
class="form-field glass"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Max Mustermann"
|
placeholder="e.g. Max Mustermann"
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
@@ -97,9 +151,11 @@
|
|||||||
/>
|
/>
|
||||||
<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>
|
||||||
<button class="btn-primary" type="submit" :disabled="submitting">
|
<div class="rsvp-form__submit glow-border glow-border--animated">
|
||||||
|
<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>
|
||||||
@@ -108,10 +164,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { 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 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'
|
||||||
|
|
||||||
@@ -119,7 +177,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, getOrganizerToken } = useEventStorage()
|
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage()
|
||||||
|
|
||||||
const state = ref<State>('loading')
|
const state = ref<State>('loading')
|
||||||
const event = ref<GetEventResponse | null>(null)
|
const event = ref<GetEventResponse | null>(null)
|
||||||
@@ -131,7 +189,30 @@ 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)
|
||||||
|
|
||||||
|
// 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 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 ''
|
||||||
@@ -147,8 +228,8 @@ async function fetchEvent() {
|
|||||||
event.value = null
|
event.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error, response } = await api.GET('/events/{token}', {
|
const { data, error, response } = await api.GET('/events/{eventToken}', {
|
||||||
params: { path: { token: route.params.eventToken as string } },
|
params: { path: { eventToken: route.params.eventToken as string } },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -160,7 +241,13 @@ async function fetchEvent() {
|
|||||||
state.value = 'loaded'
|
state.value = 'loaded'
|
||||||
|
|
||||||
// Check if current user is the organizer
|
// Check if current user is the organizer
|
||||||
isOrganizer.value = !!getOrganizerToken(event.value.eventToken)
|
const orgToken = getOrganizerToken(event.value.eventToken)
|
||||||
|
isOrganizer.value = !!orgToken
|
||||||
|
|
||||||
|
// Fetch attendee list for organizer
|
||||||
|
if (orgToken) {
|
||||||
|
fetchAttendees(event.value.eventToken, orgToken)
|
||||||
|
}
|
||||||
|
|
||||||
// Restore RSVP status from localStorage
|
// Restore RSVP status from localStorage
|
||||||
const stored = getRsvp(event.value.eventToken)
|
const stored = getRsvp(event.value.eventToken)
|
||||||
@@ -189,8 +276,8 @@ async function submitRsvp() {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await api.POST('/events/{token}/rsvps', {
|
const { data, error } = await api.POST('/events/{eventToken}/rsvps', {
|
||||||
params: { path: { token: route.params.eventToken as string } },
|
params: { path: { eventToken: route.params.eventToken as string } },
|
||||||
body: { name: nameInput.value },
|
body: { name: nameInput.value },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -220,6 +307,88 @@ async function submitRsvp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelRsvp() {
|
||||||
|
confirmCancelOpen.value = false
|
||||||
|
cancelError.value = ''
|
||||||
|
|
||||||
|
const stored = getRsvp(route.params.eventToken as string)
|
||||||
|
if (!stored) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
|
||||||
|
params: {
|
||||||
|
path: {
|
||||||
|
eventToken: route.params.eventToken as string,
|
||||||
|
rsvpToken: stored.rsvpToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204 || response.status === 404) {
|
||||||
|
removeRsvp(route.params.eventToken as string)
|
||||||
|
rsvpName.value = undefined
|
||||||
|
if (event.value) {
|
||||||
|
event.value.attendeeCount = Math.max(0, event.value.attendeeCount - 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
cancelError.value = 'Could not cancel RSVP. Please try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancelEvent() {
|
||||||
|
cancelEventError.value = ''
|
||||||
|
cancellingEvent.value = true
|
||||||
|
|
||||||
|
const orgToken = getOrganizerToken(route.params.eventToken as string)
|
||||||
|
if (!orgToken) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await api.PATCH('/events/{eventToken}', {
|
||||||
|
params: {
|
||||||
|
path: { eventToken: route.params.eventToken as string },
|
||||||
|
query: { organizerToken: orgToken },
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
cancelled: true,
|
||||||
|
cancellationReason: cancelReasonInput.value || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
cancelEventError.value = 'Could not cancel event. Please try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSheetOpen.value = false
|
||||||
|
cancelReasonInput.value = ''
|
||||||
|
await fetchEvent()
|
||||||
|
} catch {
|
||||||
|
cancelEventError.value = 'Could not cancel event. Please try again.'
|
||||||
|
} finally {
|
||||||
|
cancellingEvent.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await api.GET('/events/{eventToken}/attendees', {
|
||||||
|
params: {
|
||||||
|
path: { eventToken: eventToken },
|
||||||
|
query: { organizerToken },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
attendeeNames.value = data!.attendees.map((a) => a.name)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently degrade — don't show attendee list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(fetchEvent)
|
onMounted(fetchEvent)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -241,15 +410,19 @@ onMounted(fetchEvent)
|
|||||||
.detail__hero {
|
.detail__hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 260px;
|
height: 420px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
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 {
|
||||||
@@ -257,36 +430,10 @@ onMounted(fetchEvent)
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(0, 0, 0, 0.4) 0%,
|
var(--color-glass-overlay) 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 {
|
||||||
@@ -309,6 +456,10 @@ 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;
|
||||||
@@ -339,9 +490,12 @@ 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 {
|
||||||
@@ -360,14 +514,14 @@ onMounted(fetchEvent)
|
|||||||
.detail__section-title {
|
.detail__section-title {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: var(--color-text-muted);
|
||||||
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: rgba(255, 255, 255, 0.85);
|
color: var(--color-text-soft);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -381,12 +535,6 @@ 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;
|
||||||
@@ -396,7 +544,7 @@ onMounted(fetchEvent)
|
|||||||
|
|
||||||
/* Skeleton – shimmer on gradient */
|
/* Skeleton – shimmer on gradient */
|
||||||
.skeleton {
|
.skeleton {
|
||||||
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: linear-gradient(90deg, var(--color-glass) 25%, var(--color-glass-hover) 50%, var(--color-glass) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,4 +563,139 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cancel event button */
|
||||||
|
.detail__cancel-event {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--spacing-md) var(--content-padding);
|
||||||
|
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__cancel-event-btn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
border: 1px solid var(--color-danger-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__cancel-event-btn:hover {
|
||||||
|
background: var(--color-danger-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cancel event form (inside bottom sheet) */
|
||||||
|
.cancel-form__textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form__counter {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-form__confirm {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-danger-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>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ 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 () => {
|
||||||
@@ -58,7 +57,6 @@ 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 () => {
|
||||||
@@ -102,7 +100,6 @@ 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()
|
||||||
@@ -127,7 +124,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(3)
|
expect(errorsBefore.length).toBeGreaterThanOrEqual(2)
|
||||||
|
|
||||||
// Type into title field
|
// Type into title field
|
||||||
await wrapper.find('#title').setValue('My Event')
|
await wrapper.find('#title').setValue('My Event')
|
||||||
@@ -138,9 +135,6 @@ describe('EventCreateView', () => {
|
|||||||
|
|
||||||
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
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 () => {
|
||||||
@@ -156,7 +150,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(3)
|
expect(errorTexts.length).toBeGreaterThanOrEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
||||||
@@ -169,6 +163,9 @@ 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(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,7 +176,6 @@ 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(),
|
||||||
@@ -198,7 +194,6 @@ 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()
|
||||||
@@ -208,7 +203,6 @@ describe('EventCreateView', () => {
|
|||||||
title: 'Birthday Party',
|
title: 'Birthday Party',
|
||||||
description: 'Come celebrate!',
|
description: 'Come celebrate!',
|
||||||
location: 'Berlin',
|
location: 'Berlin',
|
||||||
expiryDate: '2026-12-24',
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -217,7 +211,6 @@ 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({
|
||||||
@@ -245,7 +238,6 @@ 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()
|
||||||
@@ -256,6 +248,5 @@ 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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ 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(() => ({
|
||||||
@@ -22,7 +25,10 @@ vi.mock('@/composables/useEventStorage', () => ({
|
|||||||
getOrganizerToken: mockGetOrganizerToken,
|
getOrganizerToken: mockGetOrganizerToken,
|
||||||
saveRsvp: mockSaveRsvp,
|
saveRsvp: mockSaveRsvp,
|
||||||
getRsvp: mockGetRsvp,
|
getRsvp: mockGetRsvp,
|
||||||
removeEvent: vi.fn(),
|
removeRsvp: vi.fn(),
|
||||||
|
saveWatch: mockSaveWatch,
|
||||||
|
isStored: mockIsStored,
|
||||||
|
removeEvent: mockRemoveEvent,
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -54,7 +60,6 @@ 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 = {}) {
|
||||||
@@ -69,6 +74,9 @@ beforeEach(() => {
|
|||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
mockGetRsvp.mockReturnValue(undefined)
|
mockGetRsvp.mockReturnValue(undefined)
|
||||||
mockGetOrganizerToken.mockReturnValue(undefined)
|
mockGetOrganizerToken.mockReturnValue(undefined)
|
||||||
|
mockIsStored.mockReturnValue(false)
|
||||||
|
mockSaveWatch.mockClear()
|
||||||
|
mockRemoveEvent.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('EventDetailView', () => {
|
describe('EventDetailView', () => {
|
||||||
@@ -124,29 +132,6 @@ 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({
|
||||||
@@ -213,7 +198,7 @@ describe('EventDetailView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
||||||
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
|
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending!")
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -229,17 +214,6 @@ describe('EventDetailView', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show RSVP bar on expired event', async () => {
|
|
||||||
mockLoadedEvent({ expired: true })
|
|
||||||
|
|
||||||
const wrapper = await mountWithToken()
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
|
||||||
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows RSVP status bar when localStorage has RSVP', async () => {
|
it('shows RSVP status bar when localStorage has RSVP', async () => {
|
||||||
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
||||||
mockLoadedEvent()
|
mockLoadedEvent()
|
||||||
@@ -262,7 +236,7 @@ describe('EventDetailView', () => {
|
|||||||
|
|
||||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
|
||||||
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
||||||
@@ -275,7 +249,7 @@ describe('EventDetailView', () => {
|
|||||||
const wrapper = await mountWithToken()
|
const wrapper = await mountWithToken()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// Form is inside Teleport — find via document.body
|
// Form is inside Teleport — find via document.body
|
||||||
@@ -300,7 +274,7 @@ describe('EventDetailView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// Open sheet
|
// Open sheet
|
||||||
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// Fill name via Teleported input
|
// Fill name via Teleported input
|
||||||
@@ -315,8 +289,8 @@ describe('EventDetailView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// Verify API call
|
// Verify API call
|
||||||
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{token}/rsvps', {
|
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{eventToken}/rsvps', {
|
||||||
params: { path: { token: 'test-token' } },
|
params: { path: { eventToken: 'test-token' } },
|
||||||
body: { name: 'Max' },
|
body: { name: 'Max' },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -339,6 +313,42 @@ describe('EventDetailView', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Attendee list (organizer)
|
||||||
|
it('shows attendee list for organizer', async () => {
|
||||||
|
mockGetOrganizerToken.mockReturnValue('org-token-123')
|
||||||
|
mockLoadedEvent()
|
||||||
|
vi.mocked(api.GET)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: fullEvent,
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 200 }),
|
||||||
|
} as never)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { attendees: [{ name: 'Alice' }, { name: 'Bob' }] },
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(null, { status: 200 }),
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('Alice')
|
||||||
|
expect(wrapper.text()).toContain('Bob')
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('2 Attendees')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show attendee list for visitor', async () => {
|
||||||
|
mockLoadedEvent()
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('shows error when RSVP submission fails', async () => {
|
it('shows error when RSVP submission fails', async () => {
|
||||||
mockLoadedEvent()
|
mockLoadedEvent()
|
||||||
vi.mocked(api.POST).mockResolvedValue({
|
vi.mocked(api.POST).mockResolvedValue({
|
||||||
@@ -350,7 +360,7 @@ describe('EventDetailView', () => {
|
|||||||
const wrapper = await mountWithToken()
|
const wrapper = await mountWithToken()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await wrapper.find('.rsvp-bar__cta').trigger('click')
|
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
|
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
|
||||||
@@ -365,4 +375,89 @@ 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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
34
specs/011-view-attendee-list/checklists/requirements.md
Normal file
34
specs/011-view-attendee-list/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: View Attendee List
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-08
|
||||||
|
**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`.
|
||||||
136
specs/011-view-attendee-list/contracts/api.md
Normal file
136
specs/011-view-attendee-list/contracts/api.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# API Contract: View Attendee List (011)
|
||||||
|
|
||||||
|
**Date**: 2026-03-08
|
||||||
|
|
||||||
|
## New Endpoint
|
||||||
|
|
||||||
|
### `GET /events/{token}/attendees`
|
||||||
|
|
||||||
|
Retrieves the list of attendees for an event. Restricted to the event organizer.
|
||||||
|
|
||||||
|
**Path Parameters**:
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| token | string (UUID) | Event token |
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| organizerToken | string (UUID) | Yes | Organizer token for authorization |
|
||||||
|
|
||||||
|
**Responses**:
|
||||||
|
|
||||||
|
#### 200 OK
|
||||||
|
|
||||||
|
Organizer token is valid. Returns the attendee list.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attendees": [
|
||||||
|
{ "name": "Alice" },
|
||||||
|
{ "name": "Bob" },
|
||||||
|
{ "name": "Charlie" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 200 OK (empty list)
|
||||||
|
|
||||||
|
No RSVPs yet.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attendees": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 403 Forbidden
|
||||||
|
|
||||||
|
Organizer token is missing, invalid, or does not match the event.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "about:blank",
|
||||||
|
"title": "Forbidden",
|
||||||
|
"status": 403,
|
||||||
|
"detail": "Invalid organizer token."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 404 Not Found
|
||||||
|
|
||||||
|
Event token does not exist.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "about:blank",
|
||||||
|
"title": "Not Found",
|
||||||
|
"status": 404,
|
||||||
|
"detail": "Event not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAPI Schema Addition
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
/events/{token}/attendees:
|
||||||
|
get:
|
||||||
|
operationId: getAttendees
|
||||||
|
summary: Get attendee list for an event (organizer only)
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: organizerToken
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Attendee list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GetAttendeesResponse'
|
||||||
|
'403':
|
||||||
|
description: Invalid organizer token
|
||||||
|
'404':
|
||||||
|
description: Event not found
|
||||||
|
|
||||||
|
GetAttendeesResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- attendees
|
||||||
|
properties:
|
||||||
|
attendees:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Attendee'
|
||||||
|
example:
|
||||||
|
- name: "Alice"
|
||||||
|
- name: "Bob"
|
||||||
|
|
||||||
|
Attendee:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 100
|
||||||
|
example: "Alice"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Existing Endpoints (unchanged)
|
||||||
|
|
||||||
|
- `POST /events` — no changes
|
||||||
|
- `GET /events/{token}` — no changes (still returns `attendeeCount` publicly)
|
||||||
|
- `POST /events/{token}/rsvps` — no changes
|
||||||
72
specs/011-view-attendee-list/data-model.md
Normal file
72
specs/011-view-attendee-list/data-model.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Data Model: View Attendee List (011)
|
||||||
|
|
||||||
|
**Date**: 2026-03-08
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Rsvp (existing — no schema changes)
|
||||||
|
|
||||||
|
The attendee list feature reads from the existing `rsvps` table. No new tables or columns are required.
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Notes |
|
||||||
|
|-------|------|-------------|-------|
|
||||||
|
| id | BIGSERIAL | PK, auto-increment | Chronological order proxy |
|
||||||
|
| rsvp_token | UUID | UNIQUE, NOT NULL | Public identifier |
|
||||||
|
| event_id | BIGINT | FK → events.id, NOT NULL | CASCADE DELETE |
|
||||||
|
| name | VARCHAR(100) | NOT NULL | Display name shown to organizer |
|
||||||
|
|
||||||
|
**Existing indexes**: `idx_rsvps_event_id` (on `event_id`), `idx_rsvps_rsvp_token` (on `rsvp_token`).
|
||||||
|
|
||||||
|
### Event (existing — no schema changes)
|
||||||
|
|
||||||
|
The `organizer_token` column on the `events` table is used for authorization. The endpoint verifies that the provided organizer token matches the event's stored token.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| organizer_token | UUID | UNIQUE, NOT NULL — used for attendee list authorization |
|
||||||
|
|
||||||
|
## Query Patterns
|
||||||
|
|
||||||
|
### Get attendees by event token
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT r.name
|
||||||
|
FROM rsvps r
|
||||||
|
JOIN events e ON r.event_id = e.id
|
||||||
|
WHERE e.event_token = :eventToken
|
||||||
|
ORDER BY r.id ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance**: Uses existing `idx_rsvps_event_id` index. Expected result set is small (spec assumes small-to-medium events, no pagination needed).
|
||||||
|
|
||||||
|
### Organizer token verification
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT e.organizer_token
|
||||||
|
FROM events e
|
||||||
|
WHERE e.event_token = :eventToken;
|
||||||
|
```
|
||||||
|
|
||||||
|
Already implemented in `EventService.getByEventToken()` — the event entity includes the organizer token. The use case compares the provided token against the stored one.
|
||||||
|
|
||||||
|
## Domain Model Changes
|
||||||
|
|
||||||
|
### New Outbound Port Method
|
||||||
|
|
||||||
|
```java
|
||||||
|
// RsvpRepository (existing interface)
|
||||||
|
List<Rsvp> findByEventId(Long eventId); // NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Inbound Port
|
||||||
|
|
||||||
|
```java
|
||||||
|
// GetAttendeesUseCase (new interface)
|
||||||
|
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a list of attendee display names. Throws `EventNotFoundException` if event token is invalid. Throws `AccessDeniedException` (or similar) if organizer token does not match.
|
||||||
|
|
||||||
|
## No Migration Required
|
||||||
|
|
||||||
|
All required data structures already exist from changeset `003-create-rsvps-table.xml`. This feature only adds read access to existing data.
|
||||||
101
specs/011-view-attendee-list/plan.md
Normal file
101
specs/011-view-attendee-list/plan.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Implementation Plan: View Attendee List
|
||||||
|
|
||||||
|
**Branch**: `011-view-attendee-list` | **Date**: 2026-03-08 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/011-view-attendee-list/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add an organizer-only attendee list to the event detail view. A new `GET /events/{token}/attendees?organizerToken=<uuid>` endpoint returns attendee names when the organizer token is valid (403 otherwise). The frontend conditionally renders the list below the attendee count when the viewer is identified as the organizer via localStorage.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
|
||||||
|
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||||
|
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
|
||||||
|
**Testing**: JUnit + Testcontainers (backend integration), Vitest (frontend unit), Playwright + MSW (E2E)
|
||||||
|
**Target Platform**: Self-hosted web application (PWA)
|
||||||
|
**Project Type**: Web application (full-stack)
|
||||||
|
**Performance Goals**: Attendee list loads within 2 seconds (SC-001)
|
||||||
|
**Constraints**: Privacy by Design — attendee names only exposed to organizer; no PII logging
|
||||||
|
**Scale/Scope**: Small-to-medium events; no pagination required (spec assumption)
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | ✅ PASS | Attendee names only exposed via organizer token verification. Non-organizers see count only (FR-003). No analytics/tracking added. |
|
||||||
|
| II. Test-Driven Methodology | ✅ PASS | Plan follows Research → Spec → Test → Implement. TDD enforced. E2E tests mandatory for both user stories. |
|
||||||
|
| III. API-First Development | ✅ PASS | New endpoint defined in OpenAPI spec first. Types generated before implementation. Response schemas include `example:` fields. |
|
||||||
|
| IV. Simplicity & Quality | ✅ PASS | Minimal new code: one endpoint, one use case, one component section. No over-engineering. |
|
||||||
|
| V. Dependency Discipline | ✅ PASS | No new dependencies introduced. |
|
||||||
|
| VI. Accessibility | ✅ PASS | Semantic HTML list for attendees. WCAG AA contrast. Keyboard-navigable. |
|
||||||
|
|
||||||
|
**Gate result**: ALL PASS — proceed to Phase 0.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/011-view-attendee-list/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
│ └── api.md # New endpoint contract
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── src/main/java/de/fete/
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── model/ # Existing: Event, Rsvp, tokens
|
||||||
|
│ │ └── port/
|
||||||
|
│ │ ├── in/
|
||||||
|
│ │ │ └── GetAttendeesUseCase.java # NEW: inbound port
|
||||||
|
│ │ └── out/
|
||||||
|
│ │ └── RsvpRepository.java # MODIFY: add findByEventId
|
||||||
|
│ ├── application/service/
|
||||||
|
│ │ └── RsvpService.java # MODIFY: implement GetAttendeesUseCase
|
||||||
|
│ ├── adapter/
|
||||||
|
│ │ ├── in/web/
|
||||||
|
│ │ │ └── EventController.java # MODIFY: add attendees endpoint
|
||||||
|
│ │ └── out/persistence/
|
||||||
|
│ │ ├── RsvpJpaRepository.java # MODIFY: add findByEventId query
|
||||||
|
│ │ └── RsvpPersistenceAdapter.java # MODIFY: implement findByEventId
|
||||||
|
│ └── src/main/resources/openapi/
|
||||||
|
│ └── api.yaml # MODIFY: add attendees endpoint + schema
|
||||||
|
├── src/test/java/de/fete/
|
||||||
|
│ ├── adapter/in/web/
|
||||||
|
│ │ └── EventControllerIntegrationTest.java # MODIFY: add attendees tests
|
||||||
|
│ └── application/service/
|
||||||
|
│ └── RsvpServiceTest.java # MODIFY: add getAttendees tests
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── views/
|
||||||
|
│ │ └── EventDetailView.vue # MODIFY: add attendee list section
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── AttendeeList.vue # NEW: attendee list component
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── schema.d.ts # REGENERATED from OpenAPI
|
||||||
|
│ └── composables/
|
||||||
|
│ └── useEventStorage.ts # NO CHANGES (read-only usage)
|
||||||
|
├── src/views/__tests__/
|
||||||
|
│ └── EventDetailView.spec.ts # MODIFY: add attendee list tests
|
||||||
|
├── src/components/__tests__/
|
||||||
|
│ └── AttendeeList.spec.ts # NEW: unit tests
|
||||||
|
└── e2e/
|
||||||
|
└── view-attendee-list.spec.ts # NEW: E2E tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Extends the existing web application structure. Backend follows hexagonal architecture with new inbound port + implementation. Frontend adds one new component integrated into the existing EventDetailView.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No constitution violations — section not applicable.
|
||||||
76
specs/011-view-attendee-list/quickstart.md
Normal file
76
specs/011-view-attendee-list/quickstart.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Quickstart: View Attendee List (011)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Java 25 (SDKMAN)
|
||||||
|
- Node.js 20+ / npm
|
||||||
|
- PostgreSQL running (or Docker for Testcontainers)
|
||||||
|
|
||||||
|
## Development Flow
|
||||||
|
|
||||||
|
### 1. Update OpenAPI spec
|
||||||
|
|
||||||
|
Edit `backend/src/main/resources/openapi/api.yaml` to add the `GET /events/{token}/attendees` endpoint and response schemas (see `contracts/api.md`).
|
||||||
|
|
||||||
|
### 2. Generate types
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend: regenerate Spring interfaces
|
||||||
|
cd backend && ./mvnw compile
|
||||||
|
|
||||||
|
# Frontend: regenerate TypeScript types
|
||||||
|
cd frontend && npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Backend implementation (TDD)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write tests first
|
||||||
|
cd backend && ./mvnw test
|
||||||
|
|
||||||
|
# Run specific test class
|
||||||
|
cd backend && ./mvnw test -Dtest=EventControllerIntegrationTest
|
||||||
|
cd backend && ./mvnw test -Dtest=RsvpServiceTest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Frontend implementation (TDD)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests
|
||||||
|
cd frontend && npm run test:unit
|
||||||
|
|
||||||
|
# E2E tests
|
||||||
|
cd frontend && npx playwright test e2e/view-attendee-list.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend full verify (includes checkstyle)
|
||||||
|
cd backend && ./mvnw verify
|
||||||
|
|
||||||
|
# Frontend build check
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
| Layer | File | Change |
|
||||||
|
|-------|------|--------|
|
||||||
|
| API Spec | `backend/src/main/resources/openapi/api.yaml` | Add endpoint + schemas |
|
||||||
|
| Port (in) | `de.fete.domain.port.in.GetAttendeesUseCase` | New interface |
|
||||||
|
| Port (out) | `de.fete.domain.port.out.RsvpRepository` | Add `findByEventId` |
|
||||||
|
| Service | `de.fete.application.service.RsvpService` | Implement use case |
|
||||||
|
| Persistence | `de.fete.adapter.out.persistence.RsvpJpaRepository` | Add query method |
|
||||||
|
| Persistence | `de.fete.adapter.out.persistence.RsvpPersistenceAdapter` | Implement port method |
|
||||||
|
| Controller | `de.fete.adapter.in.web.EventController` | Add endpoint handler |
|
||||||
|
| Frontend | `src/views/EventDetailView.vue` | Integrate AttendeeList |
|
||||||
|
| Frontend | `src/components/AttendeeList.vue` | New component |
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Backend unit test: `RsvpService.getAttendeeNames` — valid token, invalid token, no RSVPs
|
||||||
|
- [ ] Backend integration test: `GET /events/{token}/attendees` — 200, 403, 404
|
||||||
|
- [ ] Frontend unit test: `AttendeeList.vue` — renders names, empty state, loading
|
||||||
|
- [ ] Frontend unit test: `EventDetailView.vue` — shows list for organizer, hides for visitor
|
||||||
|
- [ ] E2E test: organizer sees attendee names, visitor sees count only
|
||||||
68
specs/011-view-attendee-list/research.md
Normal file
68
specs/011-view-attendee-list/research.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Research: View Attendee List (011)
|
||||||
|
|
||||||
|
**Date**: 2026-03-08 | **Status**: Complete
|
||||||
|
|
||||||
|
## 1. Organizer Token Verification Pattern
|
||||||
|
|
||||||
|
**Decision**: Query parameter `?organizerToken=<uuid>` on the new endpoint.
|
||||||
|
|
||||||
|
**Rationale**: The project uses token-based access control without persistent sessions. The organizer token is stored in localStorage on the client. Passing it as a query parameter is the simplest approach that fits the existing architecture. The `GET /events/{token}` endpoint already uses path-based token lookup; adding a query parameter for the organizer token keeps the two concerns separate.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Authorization header: More RESTful, but adds complexity without benefit — no auth framework in place, and query params are simpler for this single use case.
|
||||||
|
- Embed attendees in existing `GET /events/{token}` response: Rejected per spec clarification — separate endpoint keeps concerns clean and avoids exposing attendee data in the public response.
|
||||||
|
|
||||||
|
## 2. Endpoint Design
|
||||||
|
|
||||||
|
**Decision**: `GET /events/{token}/attendees?organizerToken=<uuid>` returns `{ attendees: [{ name: string }] }`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Nested under `/events/{token}` — resource hierarchy is clear.
|
||||||
|
- Returns an object with an `attendees` array (not a raw array) — allows future extension (e.g., adding metadata) without breaking the contract.
|
||||||
|
- Each attendee object contains only `name` — minimal data exposure per Privacy by Design.
|
||||||
|
- HTTP 403 for invalid/missing organizer token (not 401 — no authentication scheme exists).
|
||||||
|
- HTTP 404 if the event token is invalid (consistent with existing `GET /events/{token}`).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Return `{ attendees: [...], count: N }`: Rejected — count is derivable from array length, and already available on the existing event detail endpoint. Avoids redundancy.
|
||||||
|
- Include RSVP timestamp: Rejected — spec says chronological order but doesn't require displaying timestamps. Order is implicit in array position.
|
||||||
|
|
||||||
|
## 3. Backend Implementation Approach
|
||||||
|
|
||||||
|
**Decision**: New `GetAttendeesUseCase` inbound port, implemented by `RsvpService`. New `findByEventId` method on `RsvpRepository` outbound port.
|
||||||
|
|
||||||
|
**Rationale**: Follows the established hexagonal architecture pattern exactly. Each use case gets its own inbound port interface. The persistence layer already has `RsvpJpaRepository` with `countByEventId`; adding `findAllByEventIdOrderByIdAsc` is a natural extension (ID order = chronological insertion order).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add to `GetEventUseCase`: Rejected — violates single responsibility. The event detail endpoint is public; attendee retrieval is organizer-only.
|
||||||
|
- Direct repository call in controller: Rejected — violates hexagonal architecture.
|
||||||
|
|
||||||
|
## 4. Frontend Integration Approach
|
||||||
|
|
||||||
|
**Decision**: New `AttendeeList.vue` component rendered conditionally in `EventDetailView.vue` when the viewer is the organizer. Fetches attendees via separate API call after event loads.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Separate component keeps EventDetailView manageable (it's already ~300 lines).
|
||||||
|
- Separate API call (not bundled with event fetch) — the attendee list is organizer-only; non-organizers never trigger the request.
|
||||||
|
- Component placed below attendee count, before RSVP form — matches spec FR-004.
|
||||||
|
- Empty state handled within the component (FR-005).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Inline in EventDetailView without separate component: Rejected — view is already complex. A dedicated component improves readability and testability.
|
||||||
|
- Fetch attendees in the same call as event details: Not possible — separate endpoint by design.
|
||||||
|
|
||||||
|
## 5. Error Handling
|
||||||
|
|
||||||
|
**Decision**: Frontend silently degrades on 403 (does not render attendee list). No error toast or message shown.
|
||||||
|
|
||||||
|
**Rationale**: Per FR-007, the frontend "degrades gracefully by not rendering the list." If the organizer token is invalid (e.g., localStorage cleared on another device), the user sees the same view as a regular visitor. This is intentional — no confusing error states for edge cases that self-resolve.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Show error message on 403: Rejected — would confuse users who aren't expecting organizer features.
|
||||||
|
- Retry with different token: Not applicable — only one token per event in localStorage.
|
||||||
|
|
||||||
|
## 6. Accessibility Considerations
|
||||||
|
|
||||||
|
**Decision**: Attendee list rendered as semantic `<ul>` with `<li>` items. Section has a heading for screen readers. Count label uses singular/plural form.
|
||||||
|
|
||||||
|
**Rationale**: Constitution VI requires WCAG AA compliance, semantic HTML, and keyboard navigation. A list of names is naturally a `<ul>`. The heading provides structure for screen reader navigation.
|
||||||
87
specs/011-view-attendee-list/spec.md
Normal file
87
specs/011-view-attendee-list/spec.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Feature Specification: View Attendee List
|
||||||
|
|
||||||
|
**Feature Branch**: `011-view-attendee-list`
|
||||||
|
**Created**: 2026-03-08
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "der organisator soll die Teilnehmerliste einsehen können, wenn er sich die detail view eines eigenen events anschaut"
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-08
|
||||||
|
|
||||||
|
- Q: API-Design — separater Endpoint oder bestehenden erweitern? → A: Separater Endpoint `GET /events/{token}/attendees`.
|
||||||
|
- Q: Übermittlung des Organizer-Tokens? → A: Query-Parameter `?organizerToken=<uuid>`.
|
||||||
|
- Q: UI-Platzierung der Attendee-Liste auf der Detail-Seite? → A: Direkt unter dem bestehenden Attendee-Count (vor dem RSVP-Formular).
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - View Attendee List as Organizer (Priority: P1)
|
||||||
|
|
||||||
|
As the organizer of an event, I want to see a list of all attendees (people who RSVPed) when I view my event's detail page, so that I know who is coming.
|
||||||
|
|
||||||
|
When the organizer opens the event detail view for an event they created, the page displays a list of attendee names directly below the existing attendee count (before the RSVP form). This list is only visible to the organizer — regular visitors only see the attendee count (existing behavior).
|
||||||
|
|
||||||
|
**Why this priority**: This is the core feature. Without it, organizers have no way to see who signed up for their event.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating an event, submitting RSVPs from other browsers/sessions, then viewing the event detail page with the organizer token. The attendee names should be listed.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an organizer views their event with 3 RSVPs, **When** the detail page loads, **Then** the organizer sees a list showing all 3 attendee names.
|
||||||
|
2. **Given** an organizer views their event with 0 RSVPs, **When** the detail page loads, **Then** the organizer sees an empty state message indicating no one has RSVPed yet.
|
||||||
|
3. **Given** a regular visitor (non-organizer) views the same event, **When** the detail page loads, **Then** only the attendee count is shown — no individual names are visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Attendee Count Label (Priority: P2)
|
||||||
|
|
||||||
|
As the organizer, I want the attendee list to show the total count alongside the names, so I can quickly see how many people are attending at a glance.
|
||||||
|
|
||||||
|
**Why this priority**: Enhances the organizer experience but the count is already visible in the existing detail view, so this is supplementary.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by verifying the attendee count displayed next to/above the list matches the number of entries in the list.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an organizer views their event with 5 RSVPs, **When** the attendee list is displayed, **Then** a heading or label shows "5 Attendees" (or equivalent) above the list.
|
||||||
|
2. **Given** an organizer views their event with 1 RSVP, **When** the attendee list is displayed, **Then** the label uses singular form ("1 Attendee").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the organizer token stored locally is invalid or belongs to a different event? The system treats the viewer as a regular visitor and shows the count only — no error is displayed.
|
||||||
|
- What happens when an attendee name contains special characters or is very long? Names are displayed safely (escaped) and truncated visually if necessary.
|
||||||
|
- What happens if a large number of attendees (e.g. 100+) have RSVPed? The list remains scrollable and performs well without pagination (events are expected to be small-to-medium scale).
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST provide a dedicated endpoint `GET /events/{token}/attendees?organizerToken=<uuid>` for organizers to retrieve the attendee list, separate from the public event detail endpoint.
|
||||||
|
- **FR-002**: System MUST return each attendee's display name in the attendee list response.
|
||||||
|
- **FR-003**: System MUST NOT expose individual attendee names to non-organizer visitors — only the aggregate count is shown (existing behavior preserved).
|
||||||
|
- **FR-004**: The attendee list MUST be displayed directly below the attendee count on the event detail view (before the RSVP form) when the viewer is identified as the organizer.
|
||||||
|
- **FR-005**: System MUST display an empty state message when no RSVPs exist for the event.
|
||||||
|
- **FR-006**: System MUST display the total attendee count as a label alongside the attendee list.
|
||||||
|
- **FR-007**: System MUST reject attendee list requests with an invalid or missing organizer token by returning HTTP 403 (no attendee data exposed; frontend degrades gracefully by not rendering the list).
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Attendee (RSVP)**: A person who has RSVPed to an event. The organizer sees their display name in a list; visitors see only the aggregate count.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Organizers can see the full attendee name list within 2 seconds of opening their event detail page.
|
||||||
|
- **SC-002**: Non-organizer visitors never see individual attendee names — only the count is visible.
|
||||||
|
- **SC-003**: The attendee list correctly reflects all RSVPs submitted for the event, with no missing or duplicate entries.
|
||||||
|
- **SC-004**: The feature works correctly on both mobile and desktop viewports.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The organizer is identified by having a valid organizer token stored on the client. No additional login or authentication mechanism is introduced.
|
||||||
|
- The attendee list is read-only — the organizer cannot remove or edit attendees from this view.
|
||||||
|
- Attendee names are displayed in the order they RSVPed (chronological).
|
||||||
|
- The existing event detail view layout is extended, not replaced, to accommodate the attendee list section.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user