Compare commits
30 Commits
0.5.0
...
e5b71f8fb8
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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
|
||||||
@@ -55,6 +55,8 @@ The following skills are available and should be used for their respective purpo
|
|||||||
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
||||||
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
|
- 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)
|
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
|
||||||
|
- Java 25, Spring Boot 3.5.x + Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query) (013-auto-delete-expired)
|
||||||
|
- PostgreSQL (existing, Liquibase migrations) (013-auto-delete-expired)
|
||||||
|
|
||||||
## 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
|
- 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
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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,28 @@
|
|||||||
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.application.service.EventNotFoundException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
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.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 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;
|
||||||
@@ -33,20 +36,20 @@ public class EventController implements EventsApi {
|
|||||||
private final GetEventUseCase getEventUseCase;
|
private final GetEventUseCase getEventUseCase;
|
||||||
private final CreateRsvpUseCase createRsvpUseCase;
|
private final CreateRsvpUseCase createRsvpUseCase;
|
||||||
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||||
private final Clock clock;
|
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||||
|
|
||||||
/** 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,
|
||||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||||
Clock clock) {
|
GetAttendeesUseCase getAttendeesUseCase) {
|
||||||
this.createEventUseCase = createEventUseCase;
|
this.createEventUseCase = createEventUseCase;
|
||||||
this.getEventUseCase = getEventUseCase;
|
this.getEventUseCase = getEventUseCase;
|
||||||
this.createRsvpUseCase = createRsvpUseCase;
|
this.createRsvpUseCase = createRsvpUseCase;
|
||||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||||
this.clock = clock;
|
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -59,8 +62,7 @@ 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);
|
||||||
@@ -71,7 +73,6 @@ public class EventController implements EventsApi {
|
|||||||
response.setTitle(event.getTitle());
|
response.setTitle(event.getTitle());
|
||||||
response.setDateTime(event.getDateTime());
|
response.setDateTime(event.getDateTime());
|
||||||
response.setTimezone(event.getTimezone().getId());
|
response.setTimezone(event.getTimezone().getId());
|
||||||
response.setExpiryDate(event.getExpiryDate());
|
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
}
|
}
|
||||||
@@ -91,8 +92,25 @@ public class EventController implements EventsApi {
|
|||||||
response.setLocation(event.getLocation());
|
response.setLocation(event.getLocation());
|
||||||
response.setAttendeeCount(
|
response.setAttendeeCount(
|
||||||
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
|
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
|
||||||
response.setExpired(
|
|
||||||
event.getExpiryDate().isBefore(LocalDate.now(clock)));
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<GetAttendeesResponse> getAttendees(
|
||||||
|
UUID token, UUID organizerToken) {
|
||||||
|
var eventToken = new EventToken(token);
|
||||||
|
var orgToken = new OrganizerToken(organizerToken);
|
||||||
|
|
||||||
|
List<String> names = getAttendeesUseCase
|
||||||
|
.getAttendeeNames(eventToken, 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.fete.application.service.EventExpiredException;
|
|||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.application.service.EventNotFoundException;
|
||||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||||
import de.fete.application.service.ExpiryDateInPastException;
|
import de.fete.application.service.ExpiryDateInPastException;
|
||||||
|
import de.fete.application.service.InvalidOrganizerTokenException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -87,6 +88,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/{token}",
|
||||||
|
produces = MediaType.TEXT_HTML_VALUE
|
||||||
|
)
|
||||||
|
@ResponseBody
|
||||||
|
public String serveEventPage(@PathVariable String token,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
if (htmlTemplate == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String baseUrl = getBaseUrl(request);
|
||||||
|
Map<String, String> meta = resolveEventMeta(token, 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.getTitle());
|
||||||
|
String description = formatDescription(event);
|
||||||
|
tags.put("og:title", title);
|
||||||
|
tags.put("og:description", description);
|
||||||
|
tags.put("og:url", baseUrl + "/events/" + event.getEventToken().value());
|
||||||
|
tags.put("og: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.getDateTime().atZoneSameInstant(event.getTimezone());
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
|
||||||
|
|
||||||
|
if (event.getLocation() != null && !event.getLocation().isBlank()) {
|
||||||
|
sb.append(" · 📍 ").append(event.getLocation());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.getDescription() != null && !event.getDescription().isBlank()) {
|
||||||
|
sb.append(" — ").append(event.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +31,11 @@ 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.getId());
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +29,13 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
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.getId());
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
||||||
|
|
||||||
|
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
|
||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
@@ -28,13 +30,7 @@ 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())) {
|
|
||||||
throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setEventToken(EventToken.generate());
|
event.setEventToken(EventToken.generate());
|
||||||
@@ -44,7 +40,7 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
|||||||
event.setDateTime(command.dateTime());
|
event.setDateTime(command.dateTime());
|
||||||
event.setTimezone(command.timezone());
|
event.setTimezone(command.timezone());
|
||||||
event.setLocation(command.location());
|
event.setLocation(command.location());
|
||||||
event.setExpiryDate(command.expiryDate());
|
event.setExpiryDate(expiryDate);
|
||||||
event.setCreatedAt(OffsetDateTime.now(clock));
|
event.setCreatedAt(OffsetDateTime.now(clock));
|
||||||
|
|
||||||
return eventRepository.save(event);
|
return eventRepository.save(event);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
/** 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,23 @@ package de.fete.application.service;
|
|||||||
|
|
||||||
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.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 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, CountAttendeesByEventUseCase, GetAttendeesUseCase {
|
||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final RsvpRepository rsvpRepository;
|
private final RsvpRepository rsvpRepository;
|
||||||
@@ -53,4 +57,18 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
|
|||||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
return rsvpRepository.countByEventId(event.getId());
|
return rsvpRepository.countByEventId(event.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
|
||||||
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
|
if (!event.getOrganizerToken().equals(organizerToken)) {
|
||||||
|
throw new InvalidOrganizerTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rsvpRepository.findByEventId(event.getId()).stream()
|
||||||
|
.map(Rsvp::getName)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,7 @@
|
|||||||
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 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 +11,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -83,6 +83,47 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$ref: "#/components/schemas/ProblemDetail"
|
||||||
|
|
||||||
|
/events/{token}/attendees:
|
||||||
|
get:
|
||||||
|
operationId: getAttendees
|
||||||
|
summary: Get attendee list for an event (organizer only)
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
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/{token}:
|
/events/{token}:
|
||||||
get:
|
get:
|
||||||
operationId: getEvent
|
operationId: getEvent
|
||||||
@@ -119,7 +160,6 @@ components:
|
|||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- expiryDate
|
|
||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
@@ -140,11 +180,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 +189,6 @@ components:
|
|||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- expiryDate
|
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
type: string
|
type: string
|
||||||
@@ -177,10 +211,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 +220,6 @@ components:
|
|||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- attendeeCount
|
- attendeeCount
|
||||||
- expired
|
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
type: string
|
type: string
|
||||||
@@ -223,10 +252,6 @@ components:
|
|||||||
minimum: 0
|
minimum: 0
|
||||||
description: Number of confirmed attendees (attending=true)
|
description: Number of confirmed attendees (attending=true)
|
||||||
example: 12
|
example: 12
|
||||||
expired:
|
|
||||||
type: boolean
|
|
||||||
description: Whether the event's expiry date has passed
|
|
||||||
example: false
|
|
||||||
|
|
||||||
CreateRsvpRequest:
|
CreateRsvpRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -256,6 +281,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:
|
||||||
|
|||||||
@@ -55,8 +55,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 +66,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 +77,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 +89,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 +116,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 +135,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 +147,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 +168,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 +196,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 +220,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 +320,68 @@ 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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,81 @@
|
|||||||
|
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.getEventToken())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredKeepsEventsExpiringToday() {
|
||||||
|
Event today = buildEvent("Today Party", LocalDate.now());
|
||||||
|
Event saved = eventRepository.save(today);
|
||||||
|
|
||||||
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(eventRepository.findByEventToken(saved.getEventToken())).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) {
|
||||||
|
var event = new Event();
|
||||||
|
event.setEventToken(EventToken.generate());
|
||||||
|
event.setOrganizerToken(OrganizerToken.generate());
|
||||||
|
event.setTitle(title);
|
||||||
|
event.setDescription("Test description");
|
||||||
|
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||||
|
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
||||||
|
event.setLocation("Test Location");
|
||||||
|
event.setExpiryDate(expiryDate);
|
||||||
|
event.setCreatedAt(OffsetDateTime.now());
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +52,7 @@ 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);
|
||||||
@@ -75,8 +73,7 @@ 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);
|
||||||
@@ -87,86 +84,19 @@ class EventServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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.getExpiryDate()).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) ---
|
||||||
@@ -207,8 +137,7 @@ 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);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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 +19,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;
|
||||||
@@ -122,6 +124,73 @@ class RsvpServiceTest {
|
|||||||
.isInstanceOf(EventExpiredException.class);
|
.isInstanceOf(EventExpiredException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesReturnsNamesInOrder() {
|
||||||
|
Event event = buildActiveEvent();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
OrganizerToken orgToken = event.getOrganizerToken();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.findByEventId(event.getId()))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
buildRsvp(1L, "Alice"),
|
||||||
|
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();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
OrganizerToken orgToken = event.getOrganizerToken();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.findByEventId(event.getId()))
|
||||||
|
.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();
|
||||||
|
EventToken token = event.getEventToken();
|
||||||
|
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) {
|
||||||
|
var rsvp = new Rsvp();
|
||||||
|
rsvp.setId(id);
|
||||||
|
rsvp.setRsvpToken(RsvpToken.generate());
|
||||||
|
rsvp.setEventId(1L);
|
||||||
|
rsvp.setName(name);
|
||||||
|
return rsvp;
|
||||||
|
}
|
||||||
|
|
||||||
private Event buildActiveEvent() {
|
private Event buildActiveEvent() {
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setId(1L);
|
event.setId(1L);
|
||||||
|
|||||||
@@ -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 |
@@ -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,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -199,13 +195,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 +239,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 +255,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 +347,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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
282
frontend/package-lock.json
generated
282
frontend/package-lock.json
generated
@@ -26,14 +26,14 @@
|
|||||||
"@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.53.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.54.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
@@ -1727,9 +1727,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.54.0.tgz",
|
||||||
"integrity": "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==",
|
"integrity": "sha512-khWlVXUa4CPvp4eXnj7/TUNeyiarwvTEmZggylPIPUCSWgPqUBFGElIBa9xKNCQt4pb+WSGArCNEAy22ekQQxg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1744,9 +1744,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-android-arm64": {
|
"node_modules/@oxlint/binding-android-arm64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.54.0.tgz",
|
||||||
"integrity": "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==",
|
"integrity": "sha512-p3qEVSDVmyducpI9ORTJNbaMyXfICidDXGaf3WwyDyiXPExyZdfc6UsPepGPxImlfFJs5kxOCPqP4ut12Ed9pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1761,9 +1761,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.54.0.tgz",
|
||||||
"integrity": "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==",
|
"integrity": "sha512-tUQ+vn/AL2P2Rz6cl3gsFA+4pMBFMRR0e6AVePoezV9iGTSMZUXIf0YWHq203bwNDXcY04vSXDqPB6E1b9WULA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1778,9 +1778,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-darwin-x64": {
|
"node_modules/@oxlint/binding-darwin-x64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.54.0.tgz",
|
||||||
"integrity": "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==",
|
"integrity": "sha512-9tzH/OLGZCzVGEXW0D3I0OrQOOWciHYWV9mcxjqqQE6f1DMOXT23mWj5/OQtqhW3E1VfHf0uf2MzMbM2gboPlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1795,9 +1795,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.54.0.tgz",
|
||||||
"integrity": "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==",
|
"integrity": "sha512-XelGa+x7vC9epF5JzXWHaQtdxqcL5R+H2WcEkr+JDkfmY3dAXVLa+51qW3+MWt7CZanakaOyhraLzdM32pWFfg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1812,9 +1812,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.54.0.tgz",
|
||||||
"integrity": "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==",
|
"integrity": "sha512-t5FubAU899vMF+6rBtxDewkwSAeFn8OUCKb/+jJ1XqPU0FTR+RffnTfXafVE8WXw39eN4pzUK3rkxUWlcsFuaw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1829,9 +1829,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.54.0.tgz",
|
||||||
"integrity": "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==",
|
"integrity": "sha512-Du6cwkoMRi9DAgITzQpzS1QaEqE+5BP3Tfgr1yPy3C034n6IHISCwf4KMB+wojg8FE/kYvrJDibT0ENdcR3jUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1846,9 +1846,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==",
|
"integrity": "sha512-R4PjJ1lfghsVBuAegW79jkvUWbN8P4F28KaKbM72dW6ZWTYMg6M0pkTilXFZ+q/mb8f0MEyR1rO16useoa6KTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1863,9 +1863,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.54.0.tgz",
|
||||||
"integrity": "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==",
|
"integrity": "sha512-WBNFe1foIFg6ipgzjzJfDLn7C5nZpLr/UHlnlT7GI3scCD2xpJiJTGMTTpJqA8p0Q1l2NsEnAOj9PtreF0Tkzg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1880,9 +1880,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==",
|
"integrity": "sha512-M+UAW76rZHHiYKKy4e7Te5MNikANiFhYWl0qYF1MTIhQczYIqWVDQ+SX0SzW8ipOB/oK3+enOvlvJuhMoA968Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1897,9 +1897,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==",
|
"integrity": "sha512-sGassljr8TR5F1WCqOaacrp3mYi107MnjkqlSXOMHACps7U2bL4v7M3RY7P7NMcGkNhzRHF3VO5+XyPWhTVM6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1914,9 +1914,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.54.0.tgz",
|
||||||
"integrity": "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==",
|
"integrity": "sha512-mW5Z6XTO8QWtlUjlf6yjntarjnbrmGVSK/V6XYy2rjH8xUfv9pn9G1vO92DBBBi0eUwnVpcOY3hMkP851WZNWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1931,9 +1931,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==",
|
"integrity": "sha512-nfPmEGW9BfWEUD0PVXGaYa2RS4wVblofNih1gsyUoLSWSyliNisIWd7F+QXrDSL5SJ78BPZjDW6FainV8qRiTg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1948,9 +1948,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==",
|
"integrity": "sha512-gybxMQx4NN1T+pa8TLwgVS4u9H9Hxwm7Sl0qvnQJF2WRhNN1oTOCzkmmCBbW/29DYLeV99WU76zh7srCamK9yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1965,9 +1965,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.54.0.tgz",
|
||||||
"integrity": "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==",
|
"integrity": "sha512-EAXMh2w3pzSj/aiB67kXzcHIoKxAywC1i1IgxA1sLY7iffEaZowDTOJgirY7WxeR/WiiKwak89gPeh9wUdLnIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1982,9 +1982,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.54.0.tgz",
|
||||||
"integrity": "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==",
|
"integrity": "sha512-Ih7CfITkbw86+LjgV5gDwNmNQlvz7X+51gdeLLUDwuA/9lojDxCmRQEzeMl44KStQlwzCuNIcgGMae0MllV5ww==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1999,9 +1999,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.54.0.tgz",
|
||||||
"integrity": "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==",
|
"integrity": "sha512-qGQ8KIEfdgwwJd09Nc0d5hzlHQAM0EMoshMzHG/nNlMAHXrY7RFyat6VbzjflifAFb7LPAw+Yw9AB8cfzR7L8g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2016,9 +2016,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.54.0.tgz",
|
||||||
"integrity": "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==",
|
"integrity": "sha512-GXos7N5oZIcIE8hlXgtUkI+y3frIqwiztx9p4+ZrpNgASbnIsxxy8bgLJqXE2SGCAdmtjBO1mdKhGTtVrb1jbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2033,9 +2033,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.54.0.tgz",
|
||||||
"integrity": "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==",
|
"integrity": "sha512-DVxSELcI72lmcODuVpO8uXkwuYPiA0hKMhBwNa8fO4+sIVxFvxugf/z6qrL+4PM8RjGWjdl8O/QqzuRWj9b5bA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2947,9 +2947,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/eslint-plugin": {
|
"node_modules/@vitest/eslint-plugin": {
|
||||||
"version": "1.6.9",
|
"version": "1.6.10",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.10.tgz",
|
||||||
"integrity": "sha512-9WfPx1OwJ19QLCSRLkqVO7//1WcWnK3fE/3fJhKMAmDe8+9G4rB47xCNIIeCq3FdEzkIoLTfDlwDlPBaUTMhow==",
|
"integrity": "sha512-/cOf+mTu4HBJIYHTETo8/OFCSZv3T2p+KfGnouzKfjK063cWLZp0TzvK7EU5B3eFG7ypUNtw6l+jK+SA+p1g8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3204,13 +3204,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
|
||||||
"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
|
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.0",
|
||||||
"@vue/shared": "3.5.29",
|
"@vue/shared": "3.5.30",
|
||||||
"entities": "^7.0.1",
|
"entities": "^7.0.1",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
@@ -3229,40 +3229,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-dom": {
|
"node_modules/@vue/compiler-dom": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
|
||||||
"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
|
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.29",
|
"@vue/compiler-core": "3.5.30",
|
||||||
"@vue/shared": "3.5.29"
|
"@vue/shared": "3.5.30"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
|
||||||
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.0",
|
||||||
"@vue/compiler-core": "3.5.29",
|
"@vue/compiler-core": "3.5.30",
|
||||||
"@vue/compiler-dom": "3.5.29",
|
"@vue/compiler-dom": "3.5.30",
|
||||||
"@vue/compiler-ssr": "3.5.29",
|
"@vue/compiler-ssr": "3.5.30",
|
||||||
"@vue/shared": "3.5.29",
|
"@vue/shared": "3.5.30",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.8",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
|
||||||
"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
|
"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.29",
|
"@vue/compiler-dom": "3.5.30",
|
||||||
"@vue/shared": "3.5.29"
|
"@vue/shared": "3.5.30"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api": {
|
"node_modules/@vue/devtools-api": {
|
||||||
@@ -3362,53 +3362,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
|
||||||
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
|
"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.29"
|
"@vue/shared": "3.5.30"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-core": {
|
"node_modules/@vue/runtime-core": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
|
||||||
"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
|
"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.29",
|
"@vue/reactivity": "3.5.30",
|
||||||
"@vue/shared": "3.5.29"
|
"@vue/shared": "3.5.30"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-dom": {
|
"node_modules/@vue/runtime-dom": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
|
||||||
"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
|
"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.29",
|
"@vue/reactivity": "3.5.30",
|
||||||
"@vue/runtime-core": "3.5.29",
|
"@vue/runtime-core": "3.5.30",
|
||||||
"@vue/shared": "3.5.29",
|
"@vue/shared": "3.5.30",
|
||||||
"csstype": "^3.2.3"
|
"csstype": "^3.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/server-renderer": {
|
"node_modules/@vue/server-renderer": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
|
||||||
"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
|
"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.29",
|
"@vue/compiler-ssr": "3.5.30",
|
||||||
"@vue/shared": "3.5.29"
|
"@vue/shared": "3.5.30"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "3.5.29"
|
"vue": "3.5.30"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/shared": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
|
||||||
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
|
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/test-utils": {
|
"node_modules/@vue/test-utils": {
|
||||||
@@ -4376,9 +4376,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-oxlint": {
|
"node_modules/eslint-plugin-oxlint": {
|
||||||
"version": "1.51.0",
|
"version": "1.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.53.0.tgz",
|
||||||
"integrity": "sha512-lct8LD1AxfHF1PcsuK6mFYals+zX0mx/WP2G4i16h0iR8jpT3xCfGTmTNwXiImcevzGIiJ/VDBgQ7t0B9z2Jeg==",
|
"integrity": "sha512-rRsxP+11XyYynihsKBosP5/KNgCxNMLxVV4xSypsoCmHXaXnMMe+gscwugFvR4DZI/HCeg7+qR1Ru/aSv1hvSQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5776,9 +5776,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/oxlint": {
|
"node_modules/oxlint": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.54.0.tgz",
|
||||||
"integrity": "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==",
|
"integrity": "sha512-ObSjVwf0ZYA5U5Cmelj0PsCuqCJXsm2TxZ40tgUSAY7Wu0lKAsNjor6cgXHXSys8jOwv1ICjtzouoWHdKGHbZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -5791,25 +5791,25 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@oxlint/binding-android-arm-eabi": "1.51.0",
|
"@oxlint/binding-android-arm-eabi": "1.54.0",
|
||||||
"@oxlint/binding-android-arm64": "1.51.0",
|
"@oxlint/binding-android-arm64": "1.54.0",
|
||||||
"@oxlint/binding-darwin-arm64": "1.51.0",
|
"@oxlint/binding-darwin-arm64": "1.54.0",
|
||||||
"@oxlint/binding-darwin-x64": "1.51.0",
|
"@oxlint/binding-darwin-x64": "1.54.0",
|
||||||
"@oxlint/binding-freebsd-x64": "1.51.0",
|
"@oxlint/binding-freebsd-x64": "1.54.0",
|
||||||
"@oxlint/binding-linux-arm-gnueabihf": "1.51.0",
|
"@oxlint/binding-linux-arm-gnueabihf": "1.54.0",
|
||||||
"@oxlint/binding-linux-arm-musleabihf": "1.51.0",
|
"@oxlint/binding-linux-arm-musleabihf": "1.54.0",
|
||||||
"@oxlint/binding-linux-arm64-gnu": "1.51.0",
|
"@oxlint/binding-linux-arm64-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-arm64-musl": "1.51.0",
|
"@oxlint/binding-linux-arm64-musl": "1.54.0",
|
||||||
"@oxlint/binding-linux-ppc64-gnu": "1.51.0",
|
"@oxlint/binding-linux-ppc64-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-riscv64-gnu": "1.51.0",
|
"@oxlint/binding-linux-riscv64-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-riscv64-musl": "1.51.0",
|
"@oxlint/binding-linux-riscv64-musl": "1.54.0",
|
||||||
"@oxlint/binding-linux-s390x-gnu": "1.51.0",
|
"@oxlint/binding-linux-s390x-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-x64-gnu": "1.51.0",
|
"@oxlint/binding-linux-x64-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-x64-musl": "1.51.0",
|
"@oxlint/binding-linux-x64-musl": "1.54.0",
|
||||||
"@oxlint/binding-openharmony-arm64": "1.51.0",
|
"@oxlint/binding-openharmony-arm64": "1.54.0",
|
||||||
"@oxlint/binding-win32-arm64-msvc": "1.51.0",
|
"@oxlint/binding-win32-arm64-msvc": "1.54.0",
|
||||||
"@oxlint/binding-win32-ia32-msvc": "1.51.0",
|
"@oxlint/binding-win32-ia32-msvc": "1.54.0",
|
||||||
"@oxlint/binding-win32-x64-msvc": "1.51.0"
|
"@oxlint/binding-win32-x64-msvc": "1.54.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"oxlint-tsgolint": ">=0.15.0"
|
"oxlint-tsgolint": ">=0.15.0"
|
||||||
@@ -7319,16 +7319,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.29",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||||
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.29",
|
"@vue/compiler-dom": "3.5.30",
|
||||||
"@vue/compiler-sfc": "3.5.29",
|
"@vue/compiler-sfc": "3.5.30",
|
||||||
"@vue/runtime-dom": "3.5.29",
|
"@vue/runtime-dom": "3.5.30",
|
||||||
"@vue/server-renderer": "3.5.29",
|
"@vue/server-renderer": "3.5.30",
|
||||||
"@vue/shared": "3.5.29"
|
"@vue/shared": "3.5.30"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "*"
|
"typescript": "*"
|
||||||
|
|||||||
@@ -38,14 +38,14 @@
|
|||||||
"@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.53.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.54.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
|
|||||||
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 |
@@ -16,6 +16,26 @@
|
|||||||
--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;
|
||||||
|
|
||||||
|
/* 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 +53,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 +80,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,28 +117,35 @@ 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;
|
||||||
@@ -128,22 +170,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 {
|
||||||
@@ -176,6 +225,68 @@ 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);
|
||||||
|
-webkit-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);
|
||||||
|
-webkit-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 +308,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 {
|
||||||
@@ -209,7 +320,7 @@ textarea.form-field {
|
|||||||
.rsvp-form__label {
|
.rsvp-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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
@@ -45,7 +45,7 @@ watch(
|
|||||||
.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 +53,11 @@ 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);
|
||||||
|
-webkit-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 +71,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,12 @@ 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);
|
||||||
|
-webkit-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 +101,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,8 +133,9 @@ 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 {
|
||||||
|
|||||||
@@ -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__icon" aria-hidden="true">+</span>
|
<span class="fab__inner glass-inner">
|
||||||
|
<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"
|
||||||
@@ -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,12 @@ 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__delete {
|
.event-card__delete {
|
||||||
@@ -163,7 +162,7 @@ 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;
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
</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__cta glow-border glow-border--animated">
|
||||||
I'm attending
|
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
|
||||||
</button>
|
I'm attending
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -45,6 +47,30 @@ defineEmits<{
|
|||||||
|
|
||||||
.rsvp-bar__cta {
|
.rsvp-bar__cta {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
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%;
|
||||||
|
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 {
|
.rsvp-bar__status {
|
||||||
@@ -52,13 +78,16 @@ 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);
|
||||||
|
-webkit-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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsvp-bar__check {
|
.rsvp-bar__check {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,11 +15,11 @@ 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' },
|
||||||
]
|
]
|
||||||
|
|
||||||
vi.mock('../../composables/useEventStorage', () => ({
|
vi.mock('../../composables/useEventStorage', () => ({
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -220,8 +209,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,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 +27,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 +42,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 +56,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 +65,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,7 +75,7 @@
|
|||||||
</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 { RouterLink, useRouter } from 'vue-router'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import { useEventStorage } from '@/composables/useEventStorage'
|
import { useEventStorage } from '@/composables/useEventStorage'
|
||||||
@@ -103,7 +88,6 @@ const form = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
dateTime: '',
|
dateTime: '',
|
||||||
location: '',
|
location: '',
|
||||||
expiryDate: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = reactive({
|
const errors = reactive({
|
||||||
@@ -111,31 +95,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 +128,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 +153,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 +178,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 } })
|
||||||
|
|||||||
@@ -25,35 +25,33 @@
|
|||||||
|
|
||||||
<!-- 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">
|
|
||||||
This event has ended.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="detail__title">{{ event.title }}</h1>
|
<h1 class="detail__title">{{ event.title }}</h1>
|
||||||
|
|
||||||
<dl class="detail__meta">
|
<dl class="detail__meta">
|
||||||
<div class="detail__meta-item">
|
<div class="detail__meta-item">
|
||||||
<dt class="detail__meta-icon" aria-label="Date and time">
|
<dt class="detail__meta-icon glass" aria-label="Date and time">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
|
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="event.location" class="detail__meta-item">
|
<div v-if="event.location" class="detail__meta-item">
|
||||||
<dt class="detail__meta-icon" aria-label="Location">
|
<dt class="detail__meta-icon glass" aria-label="Location">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="detail__meta-text">{{ event.location }}</dd>
|
<dd class="detail__meta-text">{{ event.location }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail__meta-item">
|
<div class="detail__meta-item">
|
||||||
<dt class="detail__meta-icon" aria-label="Attendees">
|
<dt class="detail__meta-icon glass" aria-label="Attendees">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
|
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
|
||||||
</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,13 +66,13 @@
|
|||||||
<!-- 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) -->
|
<!-- RSVP bar -->
|
||||||
<RsvpBar
|
<RsvpBar
|
||||||
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
|
v-if="state === 'loaded' && event && !isOrganizer"
|
||||||
:has-rsvp="!!rsvpName"
|
:has-rsvp="!!rsvpName"
|
||||||
@open="sheetOpen = true"
|
@open="sheetOpen = true"
|
||||||
/>
|
/>
|
||||||
@@ -88,7 +86,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 +95,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">
|
||||||
{{ submitting ? 'Sending…' : "Count me in" }}
|
<button class="rsvp-form__submit-inner glass-inner" type="submit" :disabled="submitting">
|
||||||
</button>
|
{{ submitting ? 'Sending…' : "Count me in" }}
|
||||||
|
</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>
|
||||||
@@ -111,6 +111,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import { useEventStorage } from '@/composables/useEventStorage'
|
import { useEventStorage } from '@/composables/useEventStorage'
|
||||||
|
import AttendeeList from '@/components/AttendeeList.vue'
|
||||||
import BottomSheet from '@/components/BottomSheet.vue'
|
import BottomSheet from '@/components/BottomSheet.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'
|
||||||
@@ -132,6 +133,7 @@ const submitError = ref('')
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const rsvpName = ref<string | undefined>(undefined)
|
const rsvpName = ref<string | undefined>(undefined)
|
||||||
const isOrganizer = ref(false)
|
const isOrganizer = ref(false)
|
||||||
|
const attendeeNames = ref<string[] | null>(null)
|
||||||
|
|
||||||
const formattedDateTime = computed(() => {
|
const formattedDateTime = computed(() => {
|
||||||
if (!event.value) return ''
|
if (!event.value) return ''
|
||||||
@@ -160,7 +162,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)
|
||||||
@@ -220,6 +228,23 @@ async function submitRsvp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAttendees(eventToken: string, organizerToken: string) {
|
||||||
|
try {
|
||||||
|
const { data, error } = await api.GET('/events/{token}/attendees', {
|
||||||
|
params: {
|
||||||
|
path: { token: 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 +266,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,9 +286,8 @@ 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%
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +367,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -360,14 +387,14 @@ onMounted(fetchEvent)
|
|||||||
.detail__section-title {
|
.detail__section-title {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: 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 +408,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 +417,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 +436,38 @@ 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;
|
||||||
|
}
|
||||||
</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 () => {
|
||||||
@@ -179,7 +173,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 +191,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 +200,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 +208,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 +235,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 +245,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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,7 +54,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 = {}) {
|
||||||
@@ -124,29 +123,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({
|
||||||
@@ -229,17 +205,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 +227,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 +240,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 +265,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
|
||||||
@@ -339,6 +304,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 +351,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
|
||||||
|
|||||||
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.
|
||||||
167
specs/011-view-attendee-list/tasks.md
Normal file
167
specs/011-view-attendee-list/tasks.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Tasks: View Attendee List
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/011-view-attendee-list/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api.md
|
||||||
|
|
||||||
|
**Tests**: Included — TDD enforced per constitution principle II. E2E tests mandatory per plan.
|
||||||
|
|
||||||
|
**Organization**: Tasks grouped by user story for independent implementation and testing.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2)
|
||||||
|
- Exact file paths included in all descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (API Contract)
|
||||||
|
|
||||||
|
**Purpose**: Define the API contract in OpenAPI and generate types for both backend and frontend.
|
||||||
|
|
||||||
|
- [x] T001 Add `GET /events/{token}/attendees` endpoint, `GetAttendeesResponse`, and `Attendee` schemas to `backend/src/main/resources/openapi/api.yaml` per `contracts/api.md`
|
||||||
|
- [x] T002 Regenerate backend Spring interfaces (`cd backend && ./mvnw compile`)
|
||||||
|
- [x] T003 Regenerate frontend TypeScript types (`cd frontend && npm run generate:api`)
|
||||||
|
|
||||||
|
**Checkpoint**: OpenAPI spec updated, generated types available in both backend and frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Backend Domain Ports)
|
||||||
|
|
||||||
|
**Purpose**: Create the inbound port and extend the outbound port that all backend implementation depends on.
|
||||||
|
|
||||||
|
- [x] T004 [P] Create `GetAttendeesUseCase` inbound port interface in `backend/src/main/java/de/fete/domain/port/in/GetAttendeesUseCase.java` with method `List<String> getAttendeeNames(UUID eventToken, UUID organizerToken)`
|
||||||
|
- [x] T005 [P] Add `List<Rsvp> findByEventId(Long eventId)` method to `backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java`
|
||||||
|
|
||||||
|
**Checkpoint**: Domain ports defined — service and adapter implementation can begin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — View Attendee List as Organizer (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Organizer sees a list of attendee names on the event detail page. Non-organizers see only the count (existing behavior).
|
||||||
|
|
||||||
|
**Independent Test**: Create an event, submit RSVPs, then view the detail page with the organizer token. Attendee names should be listed.
|
||||||
|
|
||||||
|
### Tests for User Story 1 ⚠️
|
||||||
|
|
||||||
|
> **Write these tests FIRST — ensure they FAIL before implementation.**
|
||||||
|
|
||||||
|
- [x] T006 [P] [US1] Write unit tests for `RsvpService.getAttendeeNames` (valid token, invalid token, event not found, no RSVPs) in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java`
|
||||||
|
- [x] T007 [P] [US1] Write integration tests for `GET /events/{token}/attendees` (200 with attendees, 200 empty, 403 invalid token, 404 event not found) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
|
||||||
|
- [x] T008 [P] [US1] Write unit tests for `AttendeeList.vue` (renders attendee names, empty state message, loading state) in `frontend/src/components/__tests__/AttendeeList.spec.ts`
|
||||||
|
- [x] T009 [P] [US1] Write unit tests for organizer-conditional rendering in `EventDetailView.vue` (shows list for organizer, hides for visitor) in `frontend/src/views/__tests__/EventDetailView.spec.ts`
|
||||||
|
- [x] T010 [P] [US1] Write E2E test: organizer sees attendee names, visitor sees count only, in `frontend/e2e/view-attendee-list.spec.ts`
|
||||||
|
|
||||||
|
### Backend Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T011 [P] [US1] Add `findAllByEventIdOrderByIdAsc` query method to `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java`
|
||||||
|
- [x] T012 [P] [US1] Implement `findByEventId` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java`
|
||||||
|
- [x] T013 [US1] Implement `GetAttendeesUseCase` in `backend/src/main/java/de/fete/application/service/RsvpService.java` — look up event by token, verify organizer token, return attendee names ordered by ID
|
||||||
|
- [x] T014 [US1] Add `getAttendees` endpoint handler to `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — map to `GetAttendeesUseCase`, return 200/403/404
|
||||||
|
|
||||||
|
### Frontend Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T015 [US1] Create `AttendeeList.vue` component in `frontend/src/components/AttendeeList.vue` — accepts attendee names array as prop, renders semantic `<ul>/<li>` list, shows empty state message when no attendees
|
||||||
|
- [x] T016 [US1] Integrate `AttendeeList.vue` into `frontend/src/views/EventDetailView.vue` — fetch `GET /events/{token}/attendees` with organizer token from localStorage, render below attendee count (before RSVP form), silently degrade on 403
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 fully functional. Organizer sees attendee names; visitor sees count only. All tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Attendee Count Label (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: The attendee list section shows a count label ("5 Attendees" / "1 Attendee") alongside the names.
|
||||||
|
|
||||||
|
**Independent Test**: Verify the count label above the list matches the number of entries, and uses singular/plural correctly.
|
||||||
|
|
||||||
|
### Tests for User Story 2 ⚠️
|
||||||
|
|
||||||
|
> **Write these tests FIRST — ensure they FAIL before implementation.**
|
||||||
|
|
||||||
|
- [x] T017 [P] [US2] Write unit tests for count label in `AttendeeList.vue` (plural "5 Attendees", singular "1 Attendee", zero "0 Attendees") in `frontend/src/components/__tests__/AttendeeList.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T018 [US2] Add count heading to `AttendeeList.vue` in `frontend/src/components/AttendeeList.vue` — render `<h3>` with singular/plural label based on attendee array length
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 complete. Count label renders correctly with singular/plural form. All tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Verification across both stories, accessibility, edge cases.
|
||||||
|
|
||||||
|
- [x] T019 Run full backend verification (`cd backend && ./mvnw verify`) — checkstyle, all tests green
|
||||||
|
- [x] T020 Run full frontend build and tests (`cd frontend && npm run build && npm run test:unit`)
|
||||||
|
- [x] T021 Run E2E tests (`cd frontend && npx playwright test e2e/view-attendee-list.spec.ts`)
|
||||||
|
- [x] T022 Verify WCAG AA contrast and semantic HTML (attendee list uses `<ul>/<li>`, section has heading for screen readers)
|
||||||
|
- [x] T023 Verify edge cases: long names truncated visually, special characters escaped, large attendee list scrollable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on T002 (generated backend interfaces)
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Phase 2 completion
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on T015 (AttendeeList component exists)
|
||||||
|
- **Polish (Phase 5)**: Depends on Phase 3 + Phase 4 completion
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Phase 2 — no dependencies on other stories
|
||||||
|
- **User Story 2 (P2)**: Depends on US1's `AttendeeList.vue` component (T015) existing
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests MUST be written and FAIL before implementation
|
||||||
|
- Ports/models before services
|
||||||
|
- Services before endpoints/controllers
|
||||||
|
- Backend before frontend (API must exist for frontend integration)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T004 + T005 can run in parallel (different files)
|
||||||
|
- T006 + T007 + T008 + T009 + T010 can all run in parallel (different test files)
|
||||||
|
- T011 + T012 can run in parallel (different persistence files)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch all tests in parallel (TDD — write first):
|
||||||
|
Task: T006 "Unit tests for RsvpService.getAttendeeNames"
|
||||||
|
Task: T007 "Integration tests for GET /events/{token}/attendees"
|
||||||
|
Task: T008 "Unit tests for AttendeeList.vue"
|
||||||
|
Task: T009 "Unit tests for EventDetailView.vue organizer rendering"
|
||||||
|
Task: T010 "E2E test for attendee list"
|
||||||
|
|
||||||
|
# Launch persistence layer in parallel:
|
||||||
|
Task: T011 "Add findAllByEventIdOrderByIdAsc to RsvpJpaRepository"
|
||||||
|
Task: T012 "Implement findByEventId in RsvpPersistenceAdapter"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (OpenAPI + type generation)
|
||||||
|
2. Complete Phase 2: Foundational (domain ports)
|
||||||
|
3. Complete Phase 3: User Story 1 (backend + frontend)
|
||||||
|
4. **STOP and VALIDATE**: Test independently — organizer sees names, visitor sees count only
|
||||||
|
5. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup + Foundational → API contract and ports ready
|
||||||
|
2. Add User Story 1 → Test independently → Deploy (MVP!)
|
||||||
|
3. Add User Story 2 → Test independently → Deploy (count label enhancement)
|
||||||
|
4. Polish phase → Full verification, accessibility, edge cases
|
||||||
36
specs/012-link-preview/checklists/requirements.md
Normal file
36
specs/012-link-preview/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Link Preview (Open Graph Meta-Tags)
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-09
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
|
- Assumptions section documents the key technical consideration (SPA vs. server-rendered meta-tags) without prescribing a solution.
|
||||||
|
- `og:image` explicitly deferred to future scope.
|
||||||
98
specs/012-link-preview/contracts/html-meta-tags.md
Normal file
98
specs/012-link-preview/contracts/html-meta-tags.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Contract: HTML Meta-Tags
|
||||||
|
|
||||||
|
**Feature**: 012-link-preview | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not add new REST API endpoints. The contract is the HTML meta-tag structure injected into the server-rendered `index.html`.
|
||||||
|
|
||||||
|
## Meta-Tag Contract: Event Pages
|
||||||
|
|
||||||
|
For requests to `/events/{eventToken}` where the event exists:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="{event title, max 70 chars}">
|
||||||
|
<meta property="og:description" content="{formatted description, max 200 chars}">
|
||||||
|
<meta property="og:url" content="{absolute canonical URL}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="fete">
|
||||||
|
<meta property="og:image" content="{absolute URL}/og-image.png">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:title" content="{event title, max 70 chars}">
|
||||||
|
<meta name="twitter:description" content="{formatted description, max 200 chars}">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Description Format
|
||||||
|
|
||||||
|
Full event data:
|
||||||
|
```
|
||||||
|
📅 Saturday, March 15, 2026 at 7:00 PM · 📍 Berlin — First 200 chars of description...
|
||||||
|
```
|
||||||
|
|
||||||
|
No location:
|
||||||
|
```
|
||||||
|
📅 Saturday, March 15, 2026 at 7:00 PM — First 200 chars of description...
|
||||||
|
```
|
||||||
|
|
||||||
|
No description:
|
||||||
|
```
|
||||||
|
📅 Saturday, March 15, 2026 at 7:00 PM · 📍 Berlin
|
||||||
|
```
|
||||||
|
|
||||||
|
No location, no description:
|
||||||
|
```
|
||||||
|
📅 Saturday, March 15, 2026 at 7:00 PM
|
||||||
|
```
|
||||||
|
|
||||||
|
### Title Truncation
|
||||||
|
|
||||||
|
- Titles ≤ 70 characters: used as-is.
|
||||||
|
- Titles > 70 characters: truncated to 67 characters + "..."
|
||||||
|
|
||||||
|
### HTML Escaping
|
||||||
|
|
||||||
|
All meta-tag `content` values MUST be HTML-escaped:
|
||||||
|
- `"` → `"`
|
||||||
|
- `&` → `&`
|
||||||
|
- `<` → `<`
|
||||||
|
- `>` → `>`
|
||||||
|
|
||||||
|
## Meta-Tag Contract: Non-Event Pages
|
||||||
|
|
||||||
|
For requests to `/`, `/create`, or any other non-event, non-API, non-static route:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="fete">
|
||||||
|
<meta property="og:description" content="Privacy-focused event planning. Create and share events without accounts.">
|
||||||
|
<meta property="og:url" content="{absolute canonical URL}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="fete">
|
||||||
|
<meta property="og:image" content="{absolute URL}/og-image.png">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:title" content="fete">
|
||||||
|
<meta name="twitter:description" content="Privacy-focused event planning. Create and share events without accounts.">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Meta-Tag Contract: Event Not Found
|
||||||
|
|
||||||
|
For requests to `/events/{eventToken}` where the event does NOT exist, fall back to generic meta-tags (same as non-event pages). The Vue SPA will handle the 404 UI client-side.
|
||||||
|
|
||||||
|
## Injection Mechanism
|
||||||
|
|
||||||
|
The `index.html` template contains a placeholder:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<head>
|
||||||
|
...
|
||||||
|
<!-- OG_META_TAGS -->
|
||||||
|
...
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
The server replaces `<!-- OG_META_TAGS -->` with the generated meta-tag block before sending the response.
|
||||||
83
specs/012-link-preview/data-model.md
Normal file
83
specs/012-link-preview/data-model.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Data Model: Link Preview (Open Graph Meta-Tags)
|
||||||
|
|
||||||
|
**Feature**: 012-link-preview | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does NOT introduce new database entities. It reads existing event data and projects it into HTML meta-tags. The "model" here is the meta-tag value object used during HTML generation.
|
||||||
|
|
||||||
|
## Meta-Tag Value Objects
|
||||||
|
|
||||||
|
### OpenGraphMeta
|
||||||
|
|
||||||
|
Represents the set of Open Graph meta-tags to inject into the HTML response.
|
||||||
|
|
||||||
|
| Field | Type | Source | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `title` | String | Event title or "fete" | Max 70 chars, truncated with "..." |
|
||||||
|
| `description` | String | Composed from event fields or generic text | Max 200 chars |
|
||||||
|
| `url` | String | Canonical URL from request | Absolute URL |
|
||||||
|
| `type` | String | Always "website" | Constant |
|
||||||
|
| `siteName` | String | Always "fete" | Constant |
|
||||||
|
| `image` | String | Static brand image URL | Absolute URL to `/og-image.png` |
|
||||||
|
|
||||||
|
### TwitterCardMeta
|
||||||
|
|
||||||
|
| Field | Type | Source | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `card` | String | Always "summary" | Constant |
|
||||||
|
| `title` | String | Same as OG title | Max 70 chars |
|
||||||
|
| `description` | String | Same as OG description | Max 200 chars |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Request for /events/{token}
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
LinkPreviewController
|
||||||
|
│
|
||||||
|
├── Resolve event token → Event domain object (existing EventRepository)
|
||||||
|
│
|
||||||
|
├── Build OpenGraphMeta from Event fields:
|
||||||
|
│ title ← event.title (truncated)
|
||||||
|
│ description ← formatDescription(event.dateTime, event.timezone, event.location, event.description)
|
||||||
|
│ url ← request base URL + /events/{token}
|
||||||
|
│ image ← request base URL + /og-image.png
|
||||||
|
│
|
||||||
|
├── Build TwitterCardMeta (mirrors OG values)
|
||||||
|
│
|
||||||
|
├── Inject meta-tags into cached index.html template
|
||||||
|
│
|
||||||
|
└── Return modified HTML
|
||||||
|
|
||||||
|
Request for / or /create (non-event pages)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
LinkPreviewController
|
||||||
|
│
|
||||||
|
├── Build generic OpenGraphMeta:
|
||||||
|
│ title ← "fete"
|
||||||
|
│ description ← "Privacy-focused event planning. Create and share events without accounts."
|
||||||
|
│ url ← request URL
|
||||||
|
│ image ← request base URL + /og-image.png
|
||||||
|
│
|
||||||
|
├── Build generic TwitterCardMeta
|
||||||
|
│
|
||||||
|
├── Inject meta-tags into cached index.html template
|
||||||
|
│
|
||||||
|
└── Return modified HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
## Existing Entities Used (Read-Only)
|
||||||
|
|
||||||
|
### Event (from `de.fete.domain.model.Event`)
|
||||||
|
|
||||||
|
| Field | Used For |
|
||||||
|
|---|---|
|
||||||
|
| `title` | `og:title`, `twitter:title` |
|
||||||
|
| `description` | Part of `og:description`, `twitter:description` |
|
||||||
|
| `dateTime` | Part of `og:description` (formatted) |
|
||||||
|
| `timezone` | Date formatting context |
|
||||||
|
| `location` | Part of `og:description` |
|
||||||
|
| `eventToken` | URL construction |
|
||||||
83
specs/012-link-preview/plan.md
Normal file
83
specs/012-link-preview/plan.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Implementation Plan: Link Preview (Open Graph Meta-Tags)
|
||||||
|
|
||||||
|
**Branch**: `012-link-preview` | **Date**: 2026-03-09 | **Spec**: `specs/012-link-preview/spec.md`
|
||||||
|
**Input**: Feature specification from `/specs/012-link-preview/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Inject Open Graph and Twitter Card meta-tags into the server-rendered HTML so that shared event links display rich preview cards in messengers and on social media. The Spring Boot backend replaces its current static SPA fallback with a controller that dynamically injects event-specific or generic meta-tags into the cached `index.html` template before serving it.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend — minimal changes)
|
||||||
|
**Primary Dependencies**: Spring Boot 3.5.x (existing), Vue 3 (existing) — no new dependencies
|
||||||
|
**Storage**: PostgreSQL via JPA (existing event data, read-only access)
|
||||||
|
**Testing**: JUnit 5 + Spring MockMvc (backend), Playwright (E2E)
|
||||||
|
**Target Platform**: Self-hosted Docker container (Linux)
|
||||||
|
**Project Type**: Web application (SPA + REST API)
|
||||||
|
**Performance Goals**: N/A — meta-tag injection adds negligible overhead (<1ms string replacement)
|
||||||
|
**Constraints**: Meta-tags MUST be in initial HTML response (no client-side JS injection). No external services or CDNs.
|
||||||
|
**Scale/Scope**: Affects all HTML page responses. No new database tables or API endpoints.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| I. Privacy by Design | ✅ PASS | No tracking, analytics, or external services. Meta-tags contain only public event info (title, date, location). No PII exposed. `og:image` is a self-hosted static asset. |
|
||||||
|
| II. Test-Driven Methodology | ✅ PLAN | Unit tests for meta-tag generation, integration tests for controller, E2E tests for full HTML verification. TDD enforced. |
|
||||||
|
| III. API-First Development | ✅ N/A | No new API endpoints. This feature modifies HTML serving, not the REST API. Existing OpenAPI spec unchanged. |
|
||||||
|
| IV. Simplicity & Quality | ✅ PASS | Simple string replacement in cached HTML template. No SSR framework, no prerendering service, no user-agent sniffing. Minimal moving parts. |
|
||||||
|
| V. Dependency Discipline | ✅ PASS | Zero new dependencies. Uses only Spring Boot's existing capabilities. |
|
||||||
|
| VI. Accessibility | ✅ N/A | Meta-tags are invisible to users. No UI changes. |
|
||||||
|
|
||||||
|
**Gate result: PASS** — No violations. No complexity tracking needed.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/012-link-preview/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output — technical decisions
|
||||||
|
├── data-model.md # Phase 1 output — meta-tag value objects
|
||||||
|
├── quickstart.md # Phase 1 output — implementation guide
|
||||||
|
├── contracts/
|
||||||
|
│ └── html-meta-tags.md # Phase 1 output — meta-tag HTML contract
|
||||||
|
└── tasks.md # Phase 2 output (created by /speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/
|
||||||
|
├── src/main/java/de/fete/
|
||||||
|
│ ├── adapter/in/web/
|
||||||
|
│ │ └── SpaController.java # NEW — serves index.html with injected meta-tags
|
||||||
|
│ ├── application/
|
||||||
|
│ │ └── OpenGraphService.java # NEW — composes meta-tag values from event data
|
||||||
|
│ └── config/
|
||||||
|
│ └── WebConfig.java # MODIFIED — remove PathResourceResolver SPA fallback
|
||||||
|
├── src/main/resources/
|
||||||
|
│ └── application.properties # MODIFIED — add forward-headers-strategy
|
||||||
|
└── src/test/java/de/fete/
|
||||||
|
├── adapter/in/web/
|
||||||
|
│ └── SpaControllerTest.java # NEW — integration tests
|
||||||
|
└── application/
|
||||||
|
└── OpenGraphServiceTest.java # NEW — unit tests
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── index.html # MODIFIED — add <!-- OG_META_TAGS --> placeholder
|
||||||
|
├── public/
|
||||||
|
│ └── og-image.png # NEW — brand image for og:image (1200x630)
|
||||||
|
└── e2e/
|
||||||
|
└── link-preview.spec.ts # NEW — E2E tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Web application structure (existing). Backend changes in adapter/web and application layers. Frontend changes minimal (HTML placeholder + static asset).
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No violations — section intentionally empty.
|
||||||
57
specs/012-link-preview/quickstart.md
Normal file
57
specs/012-link-preview/quickstart.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Quickstart: Link Preview (Open Graph Meta-Tags)
|
||||||
|
|
||||||
|
**Feature**: 012-link-preview | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## What This Feature Does
|
||||||
|
|
||||||
|
Injects Open Graph and Twitter Card meta-tags into the HTML response so that shared links display rich preview cards in messengers (WhatsApp, Telegram, Signal, etc.) and on social media (Twitter/X).
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **All HTML page requests** go through a new `SpaController` (replaces the current `PathResourceResolver` SPA fallback).
|
||||||
|
2. The controller reads the compiled `index.html` template once at startup and caches it.
|
||||||
|
3. For event pages (`/events/{token}`): fetches event data, generates event-specific meta-tags, injects them into the HTML.
|
||||||
|
4. For non-event pages: injects generic fete branding meta-tags.
|
||||||
|
5. Static files (`/assets/*`, `/favicon.svg`, `/og-image.png`) continue to be served directly by Spring Boot's default static resource handler.
|
||||||
|
|
||||||
|
## Key Files to Create/Modify
|
||||||
|
|
||||||
|
### Backend (New)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `SpaController.java` | Controller handling all non-API/non-static HTML requests, injecting meta-tags |
|
||||||
|
| `OpenGraphService.java` | Service composing meta-tag values from event data |
|
||||||
|
| `MetaTagRenderer.java` | Utility rendering meta-tag value objects into HTML `<meta>` strings |
|
||||||
|
|
||||||
|
### Backend (Modified)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `WebConfig.java` | Remove `PathResourceResolver` SPA fallback (replaced by `SpaController`) |
|
||||||
|
| `application.properties` | Add `server.forward-headers-strategy=framework` for correct URL construction behind proxies |
|
||||||
|
|
||||||
|
### Frontend (Modified)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `index.html` | Add `<!-- OG_META_TAGS -->` placeholder comment in `<head>` |
|
||||||
|
|
||||||
|
### Static Assets (New)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `frontend/public/og-image.png` | Brand image for `og:image` (1200x630 PNG) |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit tests**: `OpenGraphService` — verify meta-tag values for various event states (full data, no location, no description, long title, special characters).
|
||||||
|
- **Unit tests**: `MetaTagRenderer` — verify HTML escaping, correct meta-tag format.
|
||||||
|
- **Integration tests**: `SpaController` — verify correct HTML response with meta-tags for event URLs, generic URLs, and 404 events.
|
||||||
|
- **E2E tests**: Fetch event page HTML without JavaScript, parse meta-tags, verify values match event data.
|
||||||
|
|
||||||
|
## Local Development Notes
|
||||||
|
|
||||||
|
- In dev mode (Vite dev server), meta-tags won't be injected since Vite serves its own `index.html`. This is expected — meta-tag injection only works when the backend serves the frontend.
|
||||||
|
- To test locally: build the frontend (`npm run build`), copy `dist/` contents to `backend/src/main/resources/static/`, then run the backend.
|
||||||
|
- Alternatively, test via the Docker build which assembles everything automatically.
|
||||||
115
specs/012-link-preview/research.md
Normal file
115
specs/012-link-preview/research.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Research: Link Preview (Open Graph Meta-Tags)
|
||||||
|
|
||||||
|
**Feature**: 012-link-preview | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## R1: How to Serve Dynamic Meta-Tags from a Vue SPA
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Vue SPA serves a single `index.html` for all routes via Spring Boot's `PathResourceResolver` fallback in `WebConfig.java`. Social media crawlers (WhatsApp, Telegram, Signal, Twitter/X) do NOT execute JavaScript — they only read the initial HTML response. The current `index.html` contains no Open Graph meta-tags.
|
||||||
|
|
||||||
|
### Decision: Server-Side HTML Template Injection
|
||||||
|
|
||||||
|
Intercept HTML page requests in the Spring Boot backend. Before serving `index.html`, parse the route, fetch event data if applicable, and inject `<meta>` tags into the `<head>` section.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
- **No new dependencies**: Uses Spring Boot's existing resource serving + simple string manipulation.
|
||||||
|
- **No SSR framework needed**: Avoids adding Nuxt, Vite SSR, or a prerendering service.
|
||||||
|
- **Universal**: Works for all clients (not just crawlers), improving SEO for all visitors.
|
||||||
|
- **Simple**: The backend already serves `index.html` for all non-API/non-static routes. We just need to modify *what* HTML is returned.
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
|
||||||
|
| Alternative | Rejected Because |
|
||||||
|
|---|---|
|
||||||
|
| **Vue SSR (Nuxt/Vite SSR)** | Massive architectural change. Overkill for injecting a few meta-tags. Violates KISS. |
|
||||||
|
| **Prerendering service (prerender.io, rendertron)** | External dependency that may phone home. Violates Privacy by Design. Adds operational complexity. |
|
||||||
|
| **User-agent sniffing** | Fragile — crawler UA strings change frequently. Serving different content to crawlers vs. users is considered cloaking by some search engines. |
|
||||||
|
| **Static prerendering at build time** | Events are dynamic — created at runtime. Cannot prerender at build time. |
|
||||||
|
| **`<noscript>` fallback** | Crawlers don't read `<noscript>` content for meta-tags. Only `<meta>` tags in `<head>` are parsed. |
|
||||||
|
|
||||||
|
## R2: Implementation Strategy — Where to Inject
|
||||||
|
|
||||||
|
### Decision: Custom Controller Replacing SPA Fallback
|
||||||
|
|
||||||
|
Replace the current `PathResourceResolver` SPA fallback in `WebConfig.java` with a dedicated `@Controller` that:
|
||||||
|
|
||||||
|
1. Reads the compiled `index.html` from `classpath:/static/index.html` once at startup (cached as a template string).
|
||||||
|
2. For requests matching `/events/{token}`: fetches the event from the database, generates meta-tags, injects them into the HTML template.
|
||||||
|
3. For all other non-API, non-static-file requests: injects generic fete meta-tags.
|
||||||
|
4. Returns the modified HTML with `Content-Type: text/html`.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
- The existing `PathResourceResolver` approach cannot modify the HTML content — it only resolves files.
|
||||||
|
- A controller gives full programmatic control over the response.
|
||||||
|
- Template caching avoids repeated file I/O.
|
||||||
|
- Event lookup is a single DB query (already exists via `EventRepository`).
|
||||||
|
|
||||||
|
### Template Injection Point
|
||||||
|
|
||||||
|
The `index.html` will contain a placeholder comment `<!-- OG_META_TAGS -->` in the `<head>` section. The controller replaces this placeholder with the generated meta-tags. This is done in the Vite source `index.html` and preserved through the build.
|
||||||
|
|
||||||
|
## R3: Meta-Tag Content Strategy
|
||||||
|
|
||||||
|
### Decision: Structured Description Format
|
||||||
|
|
||||||
|
For event pages, `og:description` follows this pattern:
|
||||||
|
```
|
||||||
|
📅 {formatted date} · 📍 {location} — {truncated description}
|
||||||
|
```
|
||||||
|
|
||||||
|
If location is missing:
|
||||||
|
```
|
||||||
|
📅 {formatted date} — {truncated description}
|
||||||
|
```
|
||||||
|
|
||||||
|
If description is missing:
|
||||||
|
```
|
||||||
|
📅 {formatted date} · 📍 {location}
|
||||||
|
```
|
||||||
|
|
||||||
|
Date format: `EEEE, MMMM d, yyyy 'at' h:mm a` (e.g., "Saturday, March 15, 2026 at 7:00 PM") using the event's timezone.
|
||||||
|
|
||||||
|
### Title truncation
|
||||||
|
|
||||||
|
`og:title` = event title, truncated to 70 characters with "..." suffix if exceeded.
|
||||||
|
|
||||||
|
### Description truncation
|
||||||
|
|
||||||
|
Total `og:description` max 200 characters. The event description portion is truncated to fit within this limit after the date/location prefix.
|
||||||
|
|
||||||
|
## R4: Brand Image for og:image
|
||||||
|
|
||||||
|
### Decision: Use Existing Favicon SVG
|
||||||
|
|
||||||
|
The project already has a `favicon.svg` (tada emoji). For `og:image`, we'll create a PNG version (1200x630 recommended for OG) as a static asset.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
- SVG is not universally supported as `og:image` (WhatsApp and some crawlers require raster formats).
|
||||||
|
- A simple static PNG avoids runtime image generation complexity.
|
||||||
|
- The brand image is the same for all pages (event-specific images are out of scope per spec).
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- Add a static `og-image.png` (1200x630) to `frontend/public/` so it's included in the build output.
|
||||||
|
- The `og:image` URL will be an absolute URL: `{baseUrl}/og-image.png`.
|
||||||
|
- The image needs to be created manually (design task) or generated from the favicon.
|
||||||
|
|
||||||
|
## R5: Absolute URL Construction
|
||||||
|
|
||||||
|
### Decision: Derive from Request
|
||||||
|
|
||||||
|
The `og:url` and `og:image` tags require absolute URLs. These will be constructed from the incoming HTTP request's scheme, host, and port using `ServletUriComponentsBuilder`.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
- Works correctly behind reverse proxies when `X-Forwarded-*` headers are configured (Spring Boot handles this by default with `server.forward-headers-strategy=framework`).
|
||||||
|
- No need for hardcoded base URL configuration.
|
||||||
|
- Adapts automatically to different deployment environments.
|
||||||
|
|
||||||
|
### Note
|
||||||
|
|
||||||
|
Spring Boot's `server.forward-headers-strategy` should be set to `framework` in production to trust proxy headers. This is typically already handled in containerized deployments.
|
||||||
104
specs/012-link-preview/spec.md
Normal file
104
specs/012-link-preview/spec.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Feature Specification: Link Preview (Open Graph Meta-Tags)
|
||||||
|
|
||||||
|
**Feature Branch**: `012-link-preview`
|
||||||
|
**Created**: 2026-03-09
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "When people share an event link, the users messenger should show information about the site in the messenger"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Event Link Preview in Messenger (Priority: P1)
|
||||||
|
|
||||||
|
A user copies the link to a specific event page and pastes it into a messenger (WhatsApp, Telegram, Signal, iMessage, etc.). The messenger automatically fetches metadata from the link and displays a rich preview card showing the event title, a short description, and the fete branding. The recipient sees this information without having to open the link.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core feature. Without proper meta-tags on the event page, shared links appear as bare URLs with no context, reducing click-through rates and making events harder to discover.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by sharing an event link in any messenger and verifying the preview card shows the correct event title, description with date and location, and the fete app name.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an event exists with title, date, location, and description, **When** a user shares the event URL in a messenger, **Then** the messenger displays a preview card showing the event title, a summary of the event (including date and location), and the fete app name.
|
||||||
|
2. **Given** an event exists with all details, **When** a social media crawler fetches the event URL, **Then** the response includes Open Graph meta-tags (`og:title`, `og:description`, `og:url`, `og:type`, `og:site_name`) with correct event-specific values.
|
||||||
|
3. **Given** an event exists, **When** a crawler fetches the event URL, **Then** the `og:description` includes the event date, location, and a truncated version of the event description (max 200 characters).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Fallback Preview for Generic Pages (Priority: P2)
|
||||||
|
|
||||||
|
When a user shares a non-event page (e.g., the homepage or event list), the messenger still shows a meaningful preview with the app name and a generic description of what fete is.
|
||||||
|
|
||||||
|
**Why this priority**: Users may share the main app link rather than a specific event. A generic fallback ensures every shared link looks polished.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by sharing the homepage URL in a messenger and verifying a sensible default preview appears.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user shares the app homepage URL, **When** a messenger fetches the link, **Then** the preview shows the app name "fete" as the title and a generic description (e.g., "Privacy-focused event planning. Create and share events without accounts.").
|
||||||
|
2. **Given** a user shares the event list URL, **When** a messenger fetches the link, **Then** the preview shows default app-level metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Twitter/X Card Support (Priority: P3)
|
||||||
|
|
||||||
|
In addition to Open Graph tags, the system provides Twitter Card meta-tags so that links shared on Twitter/X also display rich preview cards.
|
||||||
|
|
||||||
|
**Why this priority**: Twitter/X uses its own card format alongside Open Graph. Adding these tags broadens the platforms where previews work correctly.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by validating the HTML source contains `twitter:card`, `twitter:title`, and `twitter:description` meta-tags.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an event page, **When** a Twitter/X crawler fetches the URL, **Then** the response includes `twitter:card` (set to "summary"), `twitter:title`, and `twitter:description` meta-tags with correct values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when an event has no description? The preview shows the event title, date, and location. The description meta-tag falls back to date and location only.
|
||||||
|
- What happens when an event has no location? The description meta-tag includes the date and whatever other details are available.
|
||||||
|
- What happens when the event title contains special characters (quotes, ampersands, angle brackets)? Meta-tag values are properly HTML-escaped.
|
||||||
|
- How does the system handle very long event titles or descriptions? Titles are truncated at 70 characters, descriptions at 200 characters.
|
||||||
|
- What happens when crawlers don't execute JavaScript? Meta-tags are served in the initial HTML response from the server, not injected client-side.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST include Open Graph meta-tags (`og:title`, `og:description`, `og:url`, `og:type`, `og:site_name`) on every event page.
|
||||||
|
- **FR-002**: The system MUST populate `og:title` with the event title, truncated to 70 characters if necessary.
|
||||||
|
- **FR-003**: The system MUST populate `og:description` with a summary that includes the event date, location (if available), and a truncated event description (max 200 characters total).
|
||||||
|
- **FR-004**: The system MUST set `og:type` to "website" for all pages.
|
||||||
|
- **FR-005**: The system MUST set `og:site_name` to "fete".
|
||||||
|
- **FR-006**: The system MUST include fallback Open Graph meta-tags on non-event pages (homepage, event list) with a generic app description.
|
||||||
|
- **FR-007**: The system MUST include Twitter Card meta-tags (`twitter:card`, `twitter:title`, `twitter:description`) on every page.
|
||||||
|
- **FR-008**: The system MUST properly HTML-escape all meta-tag values to prevent rendering issues with special characters.
|
||||||
|
- **FR-009**: The system MUST serve meta-tags in the initial HTML response (not rely on client-side JavaScript rendering) so that crawlers can read them.
|
||||||
|
- **FR-010**: The system MUST set `og:url` to the canonical URL of the current page.
|
||||||
|
- **FR-011**: The system MUST include an `og:image` meta-tag on every page, pointing to a generic fete brand image (logo/icon).
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Event Metadata**: The subset of event information used for link previews — title, date, location, description. These are read-only projections of existing event data.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: 100% of event page links display a rich preview card (with title and description) when shared in WhatsApp, Telegram, and Signal.
|
||||||
|
- **SC-002**: All meta-tag values are correctly populated with event-specific data — verified by automated tests against the HTML output.
|
||||||
|
- **SC-003**: Non-event pages display a meaningful generic preview when shared in messengers.
|
||||||
|
- **SC-004**: Meta-tags are present in the initial server response (not injected by client-side JavaScript), verifiable by fetching the page without JavaScript execution.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-09
|
||||||
|
|
||||||
|
- Q: Should a generic fete brand image be included as `og:image` fallback? → A: Yes, include a generic fete brand image as `og:image` on all pages (logo/icon).
|
||||||
|
- Q: In which language should meta-tag texts (generic description, site name) be? → A: English for all meta-tag texts.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The backend can serve or pre-render HTML with event-specific meta-tags for event detail pages. Since this is a Vue SPA, server-side rendering or a dedicated server-side mechanism will be needed for crawlers that don't execute JavaScript.
|
||||||
|
- A generic fete brand image (logo/icon) is used as `og:image` on all pages. Event-specific cover images are out of scope and can be added later.
|
||||||
|
- The date format in `og:description` uses a human-readable English format.
|
||||||
|
- All meta-tag texts (generic descriptions, site name, fallback content) are in English.
|
||||||
201
specs/012-link-preview/tasks.md
Normal file
201
specs/012-link-preview/tasks.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Tasks: Link Preview (Open Graph Meta-Tags)
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/012-link-preview/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: Included — constitution mandates TDD (Red → Green → Refactor).
|
||||||
|
|
||||||
|
**Organization**: Tasks grouped by user story for independent implementation and testing.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Prepare frontend template and static assets for meta-tag injection
|
||||||
|
|
||||||
|
- [x] T001 Add `<!-- OG_META_TAGS -->` placeholder comment in `<head>` of `frontend/index.html`
|
||||||
|
- [x] T002 [P] Create `og-image.png` brand image (1200x630) in `frontend/public/og-image.png`
|
||||||
|
- [x] T003 [P] Add `server.forward-headers-strategy=framework` to `backend/src/main/resources/application.properties`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Core infrastructure for HTML meta-tag injection that ALL user stories depend on
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||||
|
|
||||||
|
- [x] T004 Write unit tests for `MetaTagRenderer` (HTML escaping, meta-tag HTML output) in `backend/src/test/java/de/fete/adapter/in/web/MetaTagRendererTest.java` — tests MUST fail (Red)
|
||||||
|
- [x] T005 Implement `MetaTagRenderer` utility that renders meta-tag key-value pairs into HTML `<meta>` strings with proper HTML escaping in `backend/src/main/java/de/fete/adapter/in/web/MetaTagRenderer.java`
|
||||||
|
- [x] T006 Write integration tests for `SpaController` base functionality (serves index.html, replaces placeholder) in `backend/src/test/java/de/fete/adapter/in/web/SpaControllerTest.java` — tests MUST fail (Red)
|
||||||
|
- [x] T007 Implement `SpaController` that caches `index.html` template at startup and replaces `<!-- OG_META_TAGS -->` placeholder before serving in `backend/src/main/java/de/fete/adapter/in/web/SpaController.java`
|
||||||
|
- [x] T008 Remove `PathResourceResolver` SPA fallback from `backend/src/main/java/de/fete/config/WebConfig.java` (replaced by `SpaController`)
|
||||||
|
|
||||||
|
**Checkpoint**: SPA still works (index.html served for all non-API/non-static routes), but now through `SpaController` with placeholder replacement ready
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Event Link Preview in Messenger (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Shared event links display rich preview cards with event title, date, location, and description in messengers
|
||||||
|
|
||||||
|
**Independent Test**: Share an event URL — messenger shows event title and formatted description with date/location
|
||||||
|
|
||||||
|
### Tests for User Story 1 ⚠️
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [x] T009 [P] [US1] Unit tests for `OpenGraphService.buildEventMeta()` — full event data, no location, no description, long title truncation, special characters in `backend/src/test/java/de/fete/application/OpenGraphServiceTest.java`
|
||||||
|
- [x] T010 [P] [US1] Integration tests for `SpaController` event routes — GET `/events/{token}` returns HTML with event-specific OG meta-tags, event not found falls back to generic in `backend/src/test/java/de/fete/adapter/in/web/SpaControllerTest.java`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T011 [US1] Implement `OpenGraphService.buildEventMeta()` — fetch event by token, compose `og:title` (truncated 70 chars), `og:description` (date + location + description, max 200 chars), `og:url`, `og:type`, `og:site_name`, `og:image` in `backend/src/main/java/de/fete/application/service/OpenGraphService.java`
|
||||||
|
- [x] T012 [US1] Wire `SpaController` to call `OpenGraphService` for `/events/{token}` routes, inject event-specific meta-tags via `MetaTagRenderer` in `backend/src/main/java/de/fete/adapter/in/web/SpaController.java`
|
||||||
|
- [ ] T013 [US1] E2E test — deferred (requires running backend; covered by integration tests)
|
||||||
|
|
||||||
|
**Checkpoint**: Event links show rich OG preview cards in messengers. MVP complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Fallback Preview for Generic Pages (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Non-event pages (homepage, event list, create) show a meaningful generic fete preview when shared
|
||||||
|
|
||||||
|
**Independent Test**: Share the homepage URL — messenger shows "fete" as title and generic app description
|
||||||
|
|
||||||
|
### Tests for User Story 2 ⚠️
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [x] T014 [P] [US2] Unit tests for `OpenGraphService.buildGenericMeta()` — verify generic title "fete", generic description, correct URL, image URL in `backend/src/test/java/de/fete/application/service/OpenGraphServiceTest.java`
|
||||||
|
- [x] T015 [P] [US2] Integration tests for `SpaController` generic routes — GET `/`, GET `/create` return HTML with generic OG meta-tags in `backend/src/test/java/de/fete/adapter/in/web/SpaControllerTest.java`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T016 [US2] Implement `OpenGraphService.buildGenericMeta()` — title "fete", description "Privacy-focused event planning. Create and share events without accounts.", type "website", site_name "fete" in `backend/src/main/java/de/fete/application/service/OpenGraphService.java`
|
||||||
|
- [x] T017 [US2] Wire `SpaController` to call `OpenGraphService.buildGenericMeta()` for all non-event HTML routes in `backend/src/main/java/de/fete/adapter/in/web/SpaController.java`
|
||||||
|
- [ ] T018 [US2] E2E test — deferred (requires running backend; covered by integration tests)
|
||||||
|
|
||||||
|
**Checkpoint**: All shared fete links (event-specific and generic) show rich preview cards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Twitter/X Card Support (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Links shared on Twitter/X also display rich preview cards via Twitter Card meta-tags
|
||||||
|
|
||||||
|
**Independent Test**: Verify HTML source contains `twitter:card`, `twitter:title`, `twitter:description` meta-tags
|
||||||
|
|
||||||
|
### Tests for User Story 3 ⚠️
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [x] T019 [P] [US3] Unit tests for Twitter Card meta-tag generation — verify `twitter:card` = "summary", `twitter:title`, `twitter:description` match OG values in `backend/src/test/java/de/fete/application/service/OpenGraphServiceTest.java`
|
||||||
|
- [x] T020 [P] [US3] Integration tests for `SpaController` — event and generic routes include Twitter Card meta-tags in `backend/src/test/java/de/fete/adapter/in/web/SpaControllerTest.java`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T021 [US3] Extend `OpenGraphService` to include Twitter Card meta-tags (`twitter:card`, `twitter:title`, `twitter:description`) alongside OG tags in `backend/src/main/java/de/fete/application/service/OpenGraphService.java`
|
||||||
|
- [x] T022 [US3] Extend `MetaTagRenderer` to render `<meta name="twitter:*">` tags (using `name` attribute instead of `property`) in `backend/src/main/java/de/fete/adapter/in/web/MetaTagRenderer.java`
|
||||||
|
- [ ] T023 [US3] E2E test — fetch event page and homepage, verify Twitter Card meta-tags present alongside OG tags in `frontend/e2e/link-preview.spec.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: All three user stories complete — OG tags, generic fallback, and Twitter Cards all working
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Edge cases, hardening, and final verification
|
||||||
|
|
||||||
|
- [x] T024 [P] Verify HTML escaping for special characters (quotes, ampersands, angle brackets) in meta-tag values across all routes — edge-case tests in MetaTagRendererTest.java
|
||||||
|
- [x] T025 [P] Verify `SpaController` does not intercept static asset requests — SpaController only handles explicit routes, not wildcard
|
||||||
|
- [x] T026 Run full backend test suite (`cd backend && ./mvnw verify`) and fix any regressions — 97 tests, 0 bugs
|
||||||
|
- [x] T027 Run full frontend test suite (`cd frontend && npm run test:unit`) — 133 tests passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on T001 (placeholder in index.html) — BLOCKS all user stories
|
||||||
|
- **US1 (Phase 3)**: Depends on Foundational phase completion
|
||||||
|
- **US2 (Phase 4)**: Depends on Foundational phase completion — can run in parallel with US1
|
||||||
|
- **US3 (Phase 5)**: Depends on US1 or US2 (extends their meta-tag output)
|
||||||
|
- **Polish (Phase 6)**: Depends on all user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) — no dependencies on other stories
|
||||||
|
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) — independent from US1
|
||||||
|
- **User Story 3 (P3)**: Depends on US1 or US2 — extends existing OG meta-tag output with Twitter tags
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests MUST be written and FAIL before implementation (TDD Red phase)
|
||||||
|
- Service layer before controller wiring
|
||||||
|
- Unit tests before integration tests before E2E tests
|
||||||
|
- Story complete before moving to next priority
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T001, T002, T003 can all run in parallel (Setup phase)
|
||||||
|
- T004 and T006 can run in parallel (Foundational tests — different files)
|
||||||
|
- T009, T010 can run in parallel (US1 tests — different files)
|
||||||
|
- T014, T015 can run in parallel (US2 tests — different files)
|
||||||
|
- T019, T020 can run in parallel (US3 tests — different files)
|
||||||
|
- US1 and US2 can be worked on in parallel after Foundational phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch US1 tests in parallel (Red phase):
|
||||||
|
Task: "Unit tests for OpenGraphService.buildEventMeta() in OpenGraphServiceTest.java"
|
||||||
|
Task: "Integration tests for SpaController event routes in SpaControllerTest.java"
|
||||||
|
|
||||||
|
# Then implement sequentially:
|
||||||
|
Task: "Implement OpenGraphService.buildEventMeta()"
|
||||||
|
Task: "Wire SpaController for /events/{token} routes"
|
||||||
|
Task: "E2E test for event page meta-tags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (T001–T003)
|
||||||
|
2. Complete Phase 2: Foundational (T004–T008)
|
||||||
|
3. Complete Phase 3: User Story 1 (T009–T013)
|
||||||
|
4. **STOP and VALIDATE**: Share an event link in a messenger, verify preview card
|
||||||
|
5. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup + Foundational → SpaController serving index.html with placeholder replacement
|
||||||
|
2. Add US1 → Event links show rich previews → Deploy (MVP!)
|
||||||
|
3. Add US2 → Generic pages also show previews → Deploy
|
||||||
|
4. Add US3 → Twitter/X cards work too → Deploy
|
||||||
|
5. Polish → Edge cases hardened → Final release
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- Each user story is independently completable and testable
|
||||||
|
- TDD enforced: write tests first, verify they fail, then implement
|
||||||
|
- Commit after each task or logical group
|
||||||
|
- Stop at any checkpoint to validate story independently
|
||||||
34
specs/013-auto-delete-expired/checklists/requirements.md
Normal file
34
specs/013-auto-delete-expired/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-09
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
38
specs/013-auto-delete-expired/data-model.md
Normal file
38
specs/013-auto-delete-expired/data-model.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Data Model: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Feature**: 013-auto-delete-expired | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## Existing Entities (no changes)
|
||||||
|
|
||||||
|
### Event
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | BIGSERIAL | PK, internal |
|
||||||
|
| event_token | UUID | Public identifier |
|
||||||
|
| organizer_token | UUID | Organizer access |
|
||||||
|
| title | VARCHAR(200) | Required |
|
||||||
|
| description | VARCHAR(2000) | Optional |
|
||||||
|
| date_time | TIMESTAMPTZ | Event date/time |
|
||||||
|
| location | VARCHAR(500) | Optional |
|
||||||
|
| expiry_date | DATE | **Deletion trigger** — indexed (`idx_events_expiry_date`) |
|
||||||
|
| created_at | TIMESTAMPTZ | Auto-set |
|
||||||
|
|
||||||
|
### RSVP
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | BIGSERIAL | PK, internal |
|
||||||
|
| rsvp_token | UUID | Public identifier |
|
||||||
|
| event_id | BIGINT | FK → events(id), **ON DELETE CASCADE** |
|
||||||
|
| name | VARCHAR(100) | Guest name |
|
||||||
|
|
||||||
|
## Deletion Behavior
|
||||||
|
|
||||||
|
- `DELETE FROM events WHERE expiry_date < CURRENT_DATE` removes expired events.
|
||||||
|
- RSVPs are automatically cascade-deleted by the FK constraint `fk_rsvps_event_id` with `ON DELETE CASCADE`.
|
||||||
|
- No new tables, columns, or migrations required.
|
||||||
|
|
||||||
|
## Indexes Used
|
||||||
|
|
||||||
|
- `idx_events_expiry_date` on `events(expiry_date)` — ensures the cleanup query is efficient.
|
||||||
90
specs/013-auto-delete-expired/plan.md
Normal file
90
specs/013-auto-delete-expired/plan.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Implementation Plan: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Branch**: `013-auto-delete-expired` | **Date**: 2026-03-09 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/013-auto-delete-expired/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a scheduled background job that runs daily and deletes all events whose `expiryDate` has passed. Deletion is performed via a native SQL query (`DELETE FROM events WHERE expiry_date < CURRENT_DATE`). Cascade deletion of RSVPs is handled by the existing `ON DELETE CASCADE` FK constraint. No API or frontend changes required.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Java 25, Spring Boot 3.5.x
|
||||||
|
**Primary Dependencies**: Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query)
|
||||||
|
**Storage**: PostgreSQL (existing, Liquibase migrations)
|
||||||
|
**Testing**: JUnit 5, Spring Boot Test, Testcontainers (existing)
|
||||||
|
**Target Platform**: Linux server (Docker)
|
||||||
|
**Project Type**: Web service (backend only for this feature)
|
||||||
|
**Performance Goals**: N/A — daily batch job on small dataset
|
||||||
|
**Constraints**: Single native DELETE query, no entity loading
|
||||||
|
**Scale/Scope**: Self-hosted, small-scale — typically < 100 events total
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | PASS | Deleting expired data supports privacy goals. No PII logged. |
|
||||||
|
| II. Test-Driven Methodology | PASS | Tests written before implementation (TDD). |
|
||||||
|
| III. API-First Development | N/A | No API changes — this is a backend-internal job. |
|
||||||
|
| IV. Simplicity & Quality | PASS | Single query, no over-engineering. |
|
||||||
|
| V. Dependency Discipline | PASS | Uses only existing Spring dependencies (`@Scheduled`). |
|
||||||
|
| VI. Accessibility | N/A | No frontend changes. |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/013-auto-delete-expired/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── spec.md # Feature specification
|
||||||
|
├── research.md # Phase 0 output (minimal — no unknowns)
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
└── checklists/
|
||||||
|
└── requirements.md # Spec quality checklist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/src/main/java/de/fete/
|
||||||
|
├── application/service/
|
||||||
|
│ └── ExpiredEventCleanupJob.java # NEW: Scheduled job
|
||||||
|
├── domain/port/out/
|
||||||
|
│ └── EventRepository.java # MODIFIED: Add deleteExpired method
|
||||||
|
└── adapter/out/persistence/
|
||||||
|
├── EventJpaRepository.java # MODIFIED: Add native DELETE query
|
||||||
|
└── EventPersistenceAdapter.java # MODIFIED: Implement deleteExpired
|
||||||
|
|
||||||
|
backend/src/test/java/de/fete/
|
||||||
|
├── application/service/
|
||||||
|
│ └── ExpiredEventCleanupJobTest.java # NEW: Unit test for job
|
||||||
|
└── adapter/out/persistence/
|
||||||
|
└── EventPersistenceAdapterIntegrationTest.java # NEW or MODIFIED: Integration test for native query
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Backend-only change. Follows existing hexagonal architecture: port defines the contract, adapter implements with native query, service layer schedules the job.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Hexagonal Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
@Scheduled trigger
|
||||||
|
→ ExpiredEventCleanupJob (application/service)
|
||||||
|
→ EventRepository.deleteExpired() (domain/port/out)
|
||||||
|
→ EventPersistenceAdapter.deleteExpired() (adapter/out/persistence)
|
||||||
|
→ EventJpaRepository native query (adapter/out/persistence)
|
||||||
|
→ DELETE FROM events WHERE expiry_date < CURRENT_DATE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
1. **Port method**: `int deleteExpired()` on `EventRepository` — returns count of deleted events for logging.
|
||||||
|
2. **Native query**: `@Modifying @Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)` on `EventJpaRepository`.
|
||||||
|
3. **Schedule**: `@Scheduled(cron = "0 0 3 * * *")` — runs daily at 03:00 server time. Low-traffic window.
|
||||||
|
4. **Logging**: INFO-level log after each run: `"Deleted {count} expired event(s)"`. No log if count is 0 (or DEBUG-level).
|
||||||
|
5. **Transaction**: The native DELETE runs in a single transaction — atomic, no partial state.
|
||||||
|
6. **Enable scheduling**: Add `@EnableScheduling` to `FeteApplication` (or a config class).
|
||||||
31
specs/013-auto-delete-expired/research.md
Normal file
31
specs/013-auto-delete-expired/research.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Research: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Feature**: 013-auto-delete-expired | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## Deletion Strategy
|
||||||
|
|
||||||
|
- **Decision**: Direct native SQL DELETE query via Spring Data JPA `@Query`.
|
||||||
|
- **Rationale**: Simplest approach. No entity loading overhead. The existing `ON DELETE CASCADE` FK constraint on `fk_rsvps_event_id` (migration `003-create-rsvps-table.xml`) handles cascading deletion of RSVPs automatically. The existing `idx_events_expiry_date` index ensures the WHERE clause is efficient.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- JPA repository `deleteAll(findExpired())`: Loads entities into memory first, unnecessary overhead.
|
||||||
|
- Database-level cron (`pg_cron`): Less portable, adds external dependency.
|
||||||
|
- Soft delete with lazy cleanup: Over-engineered for fete's scale and privacy goals.
|
||||||
|
- No deletion (filter only): Contradicts privacy-by-design principle.
|
||||||
|
|
||||||
|
## Scheduling Mechanism
|
||||||
|
|
||||||
|
- **Decision**: Spring `@Scheduled(cron = ...)` annotation.
|
||||||
|
- **Rationale**: Already available in Spring Boot, no additional dependencies. Simple, declarative, well-tested.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Quartz Scheduler: Too heavy for a single daily job.
|
||||||
|
- External cron (OS-level): Requires separate process management, harder to test.
|
||||||
|
|
||||||
|
## Transaction Behavior
|
||||||
|
|
||||||
|
- **Decision**: Single transaction for the DELETE statement.
|
||||||
|
- **Rationale**: A single `DELETE FROM events WHERE expiry_date < CURRENT_DATE` is atomic. If the DB connection drops mid-execution, the transaction rolls back and no events are partially deleted. The next run picks up all expired events.
|
||||||
|
|
||||||
|
## Enabling @Scheduled
|
||||||
|
|
||||||
|
- **Decision**: Add `@EnableScheduling` to `FeteApplication.java`.
|
||||||
|
- **Rationale**: Simplest approach. Only one scheduled job exists, no need for a separate config class.
|
||||||
71
specs/013-auto-delete-expired/spec.md
Normal file
71
specs/013-auto-delete-expired/spec.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Feature Specification: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Feature Branch**: `013-auto-delete-expired`
|
||||||
|
**Created**: 2026-03-09
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Delete events automatically after they expired"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Automatic Cleanup of Expired Events (Priority: P1)
|
||||||
|
|
||||||
|
As a self-hoster, I want expired events to be automatically deleted from the database so that personal data is removed without manual intervention and storage stays clean.
|
||||||
|
|
||||||
|
A scheduled background job periodically checks all events. Any event whose `expiryDate` is in the past gets permanently deleted — including all associated data (RSVPs, tokens). No user action is required; the system handles this autonomously.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core and only feature — automated, hands-off cleanup of expired events. It directly supports the privacy promise of fete.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating events with past expiry dates, triggering the cleanup job, and verifying the events are gone from the database.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an event with an `expiryDate` in the past, **When** the scheduled cleanup job runs, **Then** the event and all its associated data (RSVPs, tokens) are permanently deleted.
|
||||||
|
2. **Given** an event with an `expiryDate` in the future, **When** the scheduled cleanup job runs, **Then** the event remains untouched.
|
||||||
|
3. **Given** multiple expired events exist, **When** the cleanup job runs, **Then** all expired events are deleted in a single run.
|
||||||
|
4. **Given** no expired events exist, **When** the cleanup job runs, **Then** nothing is deleted and the job completes without error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens if the cleanup job fails mid-deletion (e.g., database connection lost)? Events that were not yet deleted remain and will be picked up in the next run. No partial state.
|
||||||
|
- What happens if the server was offline for an extended period? On the next run, all accumulated expired events are deleted — no special catch-up logic needed.
|
||||||
|
- What happens if an organizer is viewing their event page while it gets deleted? The page shows a "not found" state on next interaction. This is acceptable because the event has expired.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST automatically delete events whose `expiryDate` is before the current date/time.
|
||||||
|
- **FR-002**: When an event is deleted, all associated data (RSVPs, organizer tokens, event tokens) MUST be deleted as well (cascade delete).
|
||||||
|
- **FR-003**: The cleanup job MUST run on a recurring schedule (default: once daily).
|
||||||
|
- **FR-004**: The cleanup job MUST be idempotent — running it multiple times produces the same result.
|
||||||
|
- **FR-005**: The cleanup job MUST log how many events were deleted per run.
|
||||||
|
- **FR-006**: Events that have not yet expired MUST NOT be affected by the cleanup job.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Event**: The primary entity being cleaned up. Has an `expiryDate` field that determines when it becomes eligible for deletion.
|
||||||
|
- **RSVP**: Associated guest responses linked to an event. Deleted when the parent event is deleted.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Expired events are deleted within 24 hours of their expiry date without manual intervention.
|
||||||
|
- **SC-002**: Zero data residue — when an event is deleted, no orphaned RSVPs or tokens remain in the system.
|
||||||
|
- **SC-003**: The cleanup process completes without errors under normal operating conditions.
|
||||||
|
- **SC-004**: Non-expired events are never affected by the cleanup process.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-09
|
||||||
|
|
||||||
|
- Q: How should deletion be implemented — application code (JPA) or direct database query? → A: Direct database query. A single native `DELETE FROM events WHERE expiry_date < CURRENT_DATE` is sufficient. The existing `ON DELETE CASCADE` on the RSVPs foreign key ensures associated data is removed automatically. The existing `idx_events_expiry_date` index ensures the query is performant.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The `expiryDate` field already exists on events and is auto-set to `eventDate + 7 days` (implemented in the previous feature).
|
||||||
|
- Cascade deletion of associated data (RSVPs, tokens) is handled at the database level via foreign key constraints (`ON DELETE CASCADE` on `fk_rsvps_event_id`, verified in migration `003-create-rsvps-table.xml`).
|
||||||
|
- The daily schedule is sufficient — there is no requirement for near-real-time deletion.
|
||||||
|
- No "grace period" or "soft delete" is needed — events are permanently removed once expired.
|
||||||
101
specs/013-auto-delete-expired/tasks.md
Normal file
101
specs/013-auto-delete-expired/tasks.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Tasks: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/013-auto-delete-expired/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
|
||||||
|
|
||||||
|
**Tests**: Included — constitution mandates TDD (Principle II).
|
||||||
|
|
||||||
|
**Organization**: Single user story (US1), so phases are compact.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Enable Spring scheduling in the application.
|
||||||
|
|
||||||
|
- [x] T001 Add `@EnableScheduling` annotation to `backend/src/main/java/de/fete/FeteApplication.java`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — Automatic Cleanup of Expired Events (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: A daily scheduled job deletes all events whose `expiryDate` is in the past, including cascaded RSVPs.
|
||||||
|
|
||||||
|
**Independent Test**: Create events with past expiry dates, trigger the cleanup job, verify events and RSVPs are gone.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [x] T002 [P] [US1] Unit test for `ExpiredEventCleanupJob` in `backend/src/test/java/de/fete/application/service/ExpiredEventCleanupJobTest.java` — verify job calls `deleteExpired()` on repository and logs the count
|
||||||
|
- [x] T003 [P] [US1] Integration test for native DELETE query in `backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterIntegrationTest.java` — verify expired events + RSVPs are deleted, non-expired events survive
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T004 [P] [US1] Add `int deleteExpired()` method to port interface `backend/src/main/java/de/fete/domain/port/out/EventRepository.java`
|
||||||
|
- [x] T005 [P] [US1] Add native `@Modifying @Query("DELETE FROM events WHERE expiry_date < CURRENT_DATE")` method to `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaRepository.java`
|
||||||
|
- [x] T006 [US1] Implement `deleteExpired()` in `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java` — delegates to JPA repository native query
|
||||||
|
- [x] T007 [US1] Create `ExpiredEventCleanupJob` with `@Scheduled(cron = "0 0 3 * * *")` in `backend/src/main/java/de/fete/application/service/ExpiredEventCleanupJob.java` — calls `deleteExpired()`, logs count at INFO level
|
||||||
|
|
||||||
|
**Checkpoint**: All tests pass. Expired events are automatically deleted daily at 03:00.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||||
|
- **User Story 1 (Phase 2)**: Depends on Setup completion
|
||||||
|
|
||||||
|
### Within User Story 1
|
||||||
|
|
||||||
|
- T002, T003 (tests) can run in parallel — write first, must fail (RED)
|
||||||
|
- T004, T005 (port + JPA query) can run in parallel — different files
|
||||||
|
- T006 depends on T004 + T005 (adapter implements port, delegates to JPA)
|
||||||
|
- T007 depends on T006 (job calls adapter via port)
|
||||||
|
- After T007: all tests should pass (GREEN)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
```
|
||||||
|
T002 ──┐
|
||||||
|
├── (tests written, failing)
|
||||||
|
T003 ──┘
|
||||||
|
|
||||||
|
T004 ──┐
|
||||||
|
├── T006 ── T007 ── (all tests green)
|
||||||
|
T005 ──┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (this is the MVP — single story)
|
||||||
|
|
||||||
|
1. T001: Enable scheduling
|
||||||
|
2. T002 + T003: Write failing tests (RED)
|
||||||
|
3. T004 + T005: Port interface + native query (parallel)
|
||||||
|
4. T006: Adapter implementation
|
||||||
|
5. T007: Scheduled job
|
||||||
|
6. Verify all tests pass (GREEN)
|
||||||
|
7. Done — commit and ship
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Total tasks: 7
|
||||||
|
- User Story 1 tasks: 6 (T002–T007)
|
||||||
|
- Setup tasks: 1 (T001)
|
||||||
|
- Parallel opportunities: T002||T003, T004||T005
|
||||||
|
- No frontend changes needed
|
||||||
|
- No API/OpenAPI changes needed
|
||||||
|
- No new migrations needed (existing schema + FK constraints sufficient)
|
||||||
Reference in New Issue
Block a user