25 Commits

Author SHA1 Message Date
bf0f4ffb7f Merge pull request 'Rename path parameter {token} to {eventToken}' (#37) from 015-rename-token-to-eventToken into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m23s
CI / build-and-publish (push) Successful in 1m12s
2026-03-12 18:11:09 +01:00
58043d1507 Rename path parameter {token} to {eventToken} in OpenAPI spec
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m18s
CI / build-and-publish (push) Has been skipped
Aligns the path parameter naming with the value object convention
used throughout the codebase (eventToken, rsvpToken, organizerToken).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:07:44 +01:00
6d7a55fdb3 Merge pull request 'Update dependency vite to v8' (#31) from renovate/vite-8.x into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m21s
CI / build-and-publish (push) Has been skipped
Merge pull request 'Update dependency vite to v8' (#31)
2026-03-12 17:55:13 +01:00
a8aacf4ee9 Merge pull request 'Update dependency vitest to v4.1.0' (#33) from renovate/vitest-monorepo into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency vitest to v4.1.0' (#33)
2026-03-12 17:55:02 +01:00
0a404ecde3 Merge pull request 'Update dependency @vitejs/plugin-vue to v6.0.5' (#32) from renovate/vitejs-plugin-vue-6.x-lockfile into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency @vitejs/plugin-vue to v6.0.5' (#32)
2026-03-12 17:54:54 +01:00
01f9e3dac1 Merge pull request 'Update dependency @vitest/eslint-plugin to v1.6.11' (#34) from renovate/vitest-eslint-plugin-1.x-lockfile into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency @vitest/eslint-plugin to v1.6.11' (#34)
2026-03-12 17:54:47 +01:00
ad607afe83 Merge pull request 'Update oxlint monorepo' (#29) from renovate/oxlint-monorepo into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update oxlint monorepo' (#29)
2026-03-12 17:54:40 +01:00
f0424223de Merge pull request 'Update dependency maven to v3.9.14' (#30) from renovate/maven-3.x into master
Some checks failed
CI / frontend-test (push) Has been cancelled
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / backend-test (push) Has been cancelled
Merge pull request 'Update dependency maven to v3.9.14' (#30)
2026-03-12 17:54:33 +01:00
7ab9068c14 Merge pull request 'Add cancel RSVP feature' (#35) from 014-cancel-rsvp into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m17s
CI / build-and-publish (push) Has been skipped
2026-03-12 17:49:34 +01:00
41bb17d5c9 Add cancel RSVP feature (backend DELETE endpoint + frontend UI)
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m18s
CI / build-and-publish (push) Has been skipped
Allows guests to cancel their RSVP via a DELETE endpoint using their
guestToken. Frontend shows cancel button in RsvpBar and clears local
storage on success. Includes unit tests, integration tests, and E2E spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:45:37 +01:00
Renovate Bot
a44b938f08 Update oxlint monorepo
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped
2026-03-12 16:03:22 +00:00
Renovate Bot
7477a953c5 Update dependency @vitest/eslint-plugin to v1.6.11
All checks were successful
CI / backend-test (push) Successful in 1m1s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped
2026-03-12 16:02:57 +00:00
Renovate Bot
7fb296b47f Update dependency vitest to v4.1.0
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
2026-03-12 15:02:35 +00:00
Renovate Bot
8ab7d345c8 Update dependency @vitejs/plugin-vue to v6.0.5
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
2026-03-12 15:02:20 +00:00
Renovate Bot
cf2139f229 Update dependency vite to v8
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 27s
CI / frontend-e2e (push) Successful in 1m14s
CI / build-and-publish (push) Has been skipped
2026-03-12 14:02:26 +00:00
Renovate Bot
79f33d659c Update dependency maven to v3.9.14
All checks were successful
CI / backend-test (push) Successful in 55s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
2026-03-12 12:02:00 +00:00
e5b71f8fb8 Merge pull request 'Update oxlint monorepo' (#28) from renovate/oxlint-monorepo into master
All checks were successful
CI / backend-test (push) Successful in 55s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #28
2026-03-12 10:10:22 +01:00
Renovate Bot
60649ae4de Update oxlint monorepo
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
2026-03-12 07:01:59 +00:00
e90aefae15 Merge pull request 'Update dependency oxlint to ~1.53.0' (#27) from renovate/oxlint-monorepo into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #27
2026-03-11 14:02:07 +01:00
Renovate Bot
622932418d Update dependency oxlint to ~1.53.0
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m11s
CI / build-and-publish (push) Has been skipped
2026-03-11 06:02:47 +00:00
a1855ff8d6 Merge pull request 'Auto-delete expired events via daily scheduled job' (#26) from 013-auto-delete-expired into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m11s
CI / build-and-publish (push) Successful in 1m8s
2026-03-09 22:01:29 +01:00
4bfaee685c Auto-delete expired events via daily scheduled cleanup job
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
Adds a Spring @Scheduled job (daily at 03:00) that deletes all events
whose expiry_date is before CURRENT_DATE using a native SQL DELETE.
RSVPs are cascade-deleted via the existing FK constraint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:58:35 +01:00
2a6a658df9 Merge pull request 'Make expiryDate an internal concern, auto-set to event date + 7 days' (#25) from auto-expiry-date into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped
2026-03-09 21:33:43 +01:00
37d378ca59 Merge pull request 'Update dependency @vitest/eslint-plugin to v1.6.10' (#22) from renovate/vitest-eslint-plugin-1.x-lockfile into master
Some checks failed
CI / backend-test (push) Successful in 58s
CI / frontend-e2e (push) Has been cancelled
CI / build-and-publish (push) Has been cancelled
CI / frontend-test (push) Has been cancelled
Reviewed-on: #22
2026-03-09 21:30:28 +01:00
0441ca0c33 Make expiryDate an internal concern, auto-set to event date + 7 days
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped
The expiry date is no longer user-facing: it is removed from the API
(request and response) and the frontend. The backend now automatically
calculates it as the event date plus 7 days. The expired banner and
RSVP-bar filtering by expired status are also removed from the UI,
since expiry is purely an internal data-retention mechanism.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:29:12 +01:00
53 changed files with 2853 additions and 620 deletions

View File

@@ -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

View File

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

View File

@@ -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>

View File

@@ -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. */

View File

@@ -15,14 +15,14 @@ import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken; import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken; import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp; import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.in.CancelRsvpUseCase;
import de.fete.domain.port.in.CountAttendeesByEventUseCase; import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase; import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.in.GetEventUseCase;
import java.time.Clock;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -37,24 +37,24 @@ public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase; private final CreateEventUseCase createEventUseCase;
private final GetEventUseCase getEventUseCase; private final GetEventUseCase getEventUseCase;
private final CreateRsvpUseCase createRsvpUseCase; private final CreateRsvpUseCase createRsvpUseCase;
private final CancelRsvpUseCase cancelRsvpUseCase;
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase; private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
private final GetAttendeesUseCase getAttendeesUseCase; private final GetAttendeesUseCase getAttendeesUseCase;
private final Clock clock;
/** Creates a new controller with the given use cases and clock. */ /** Creates a new controller with the given use cases. */
public EventController( public EventController(
CreateEventUseCase createEventUseCase, CreateEventUseCase createEventUseCase,
GetEventUseCase getEventUseCase, GetEventUseCase getEventUseCase,
CreateRsvpUseCase createRsvpUseCase, CreateRsvpUseCase createRsvpUseCase,
CancelRsvpUseCase cancelRsvpUseCase,
CountAttendeesByEventUseCase countAttendeesByEventUseCase, CountAttendeesByEventUseCase countAttendeesByEventUseCase,
GetAttendeesUseCase getAttendeesUseCase, GetAttendeesUseCase getAttendeesUseCase) {
Clock clock) {
this.createEventUseCase = createEventUseCase; this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase; this.getEventUseCase = getEventUseCase;
this.createRsvpUseCase = createRsvpUseCase; this.createRsvpUseCase = createRsvpUseCase;
this.cancelRsvpUseCase = cancelRsvpUseCase;
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase; this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
this.getAttendeesUseCase = getAttendeesUseCase; this.getAttendeesUseCase = getAttendeesUseCase;
this.clock = clock;
} }
@Override @Override
@@ -67,8 +67,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);
@@ -79,16 +78,15 @@ 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);
} }
@Override @Override
public ResponseEntity<GetEventResponse> getEvent(UUID token) { public ResponseEntity<GetEventResponse> getEvent(UUID eventToken) {
var eventToken = new de.fete.domain.model.EventToken(token); var evtToken = new EventToken(eventToken);
Event event = getEventUseCase.getByEventToken(eventToken) Event event = getEventUseCase.getByEventToken(evtToken)
.orElseThrow(() -> new EventNotFoundException(token)); .orElseThrow(() -> new EventNotFoundException(eventToken));
var response = new GetEventResponse(); var response = new GetEventResponse();
response.setEventToken(event.getEventToken().value()); response.setEventToken(event.getEventToken().value());
@@ -98,21 +96,19 @@ public class EventController implements EventsApi {
response.setTimezone(event.getTimezone().getId()); response.setTimezone(event.getTimezone().getId());
response.setLocation(event.getLocation()); response.setLocation(event.getLocation());
response.setAttendeeCount( response.setAttendeeCount(
(int) countAttendeesByEventUseCase.countByEvent(eventToken)); (int) countAttendeesByEventUseCase.countByEvent(evtToken));
response.setExpired(
event.getExpiryDate().isBefore(LocalDate.now(clock)));
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@Override @Override
public ResponseEntity<GetAttendeesResponse> getAttendees( public ResponseEntity<GetAttendeesResponse> getAttendees(
UUID token, UUID organizerToken) { UUID eventToken, UUID organizerToken) {
var eventToken = new EventToken(token); var evtToken = new EventToken(eventToken);
var orgToken = new OrganizerToken(organizerToken); var orgToken = new OrganizerToken(organizerToken);
List<String> names = getAttendeesUseCase List<String> names = getAttendeesUseCase
.getAttendeeNames(eventToken, orgToken); .getAttendeeNames(evtToken, orgToken);
var attendees = names.stream() var attendees = names.stream()
.map(name -> new Attendee().name(name)) .map(name -> new Attendee().name(name))
@@ -126,9 +122,9 @@ public class EventController implements EventsApi {
@Override @Override
public ResponseEntity<CreateRsvpResponse> createRsvp( public ResponseEntity<CreateRsvpResponse> createRsvp(
UUID token, CreateRsvpRequest createRsvpRequest) { UUID eventToken, CreateRsvpRequest createRsvpRequest) {
var eventToken = new EventToken(token); var evtToken = new EventToken(eventToken);
Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName()); Rsvp rsvp = createRsvpUseCase.createRsvp(evtToken, createRsvpRequest.getName());
var response = new CreateRsvpResponse(); var response = new CreateRsvpResponse();
response.setRsvpToken(rsvp.getRsvpToken().value()); response.setRsvpToken(rsvp.getRsvpToken().value());
@@ -137,6 +133,12 @@ public class EventController implements EventsApi {
return ResponseEntity.status(HttpStatus.CREATED).body(response); return ResponseEntity.status(HttpStatus.CREATED).body(response);
} }
@Override
public ResponseEntity<Void> cancelRsvp(UUID eventToken, UUID rsvpToken) {
cancelRsvpUseCase.cancelRsvp(new EventToken(eventToken), new RsvpToken(rsvpToken));
return ResponseEntity.noContent().build();
}
private static ZoneId parseTimezone(String timezone) { private static ZoneId parseTimezone(String timezone) {
try { try {
return ZoneId.of(timezone); return ZoneId.of(timezone);

View File

@@ -68,17 +68,17 @@ public class SpaController {
/** Serves SPA HTML with event-specific meta-tags. */ /** Serves SPA HTML with event-specific meta-tags. */
@GetMapping( @GetMapping(
value = "/events/{token}", value = "/events/{eventToken}",
produces = MediaType.TEXT_HTML_VALUE produces = MediaType.TEXT_HTML_VALUE
) )
@ResponseBody @ResponseBody
public String serveEventPage(@PathVariable String token, public String serveEventPage(@PathVariable String eventToken,
HttpServletRequest request) { HttpServletRequest request) {
if (htmlTemplate == null) { if (htmlTemplate == null) {
return ""; return "";
} }
String baseUrl = getBaseUrl(request); String baseUrl = getBaseUrl(request);
Map<String, String> meta = resolveEventMeta(token, baseUrl); Map<String, String> meta = resolveEventMeta(eventToken, baseUrl);
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta)); return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
} }

View File

@@ -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();
} }

View File

@@ -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());

View File

@@ -14,4 +14,7 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
/** Finds all RSVPs for the given event, ordered by ID ascending. */ /** Finds all RSVPs for the given event, ordered by ID ascending. */
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId); java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
/** Deletes an RSVP by event ID and RSVP token. Returns count of deleted rows. */
long deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken);
} }

View File

@@ -36,6 +36,11 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
.toList(); .toList();
} }
@Override
public boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken) {
return jpaRepository.deleteByEventIdAndRsvpToken(eventId, rsvpToken.value()) > 0;
}
private RsvpJpaEntity toEntity(Rsvp rsvp) { private RsvpJpaEntity toEntity(Rsvp rsvp) {
var entity = new RsvpJpaEntity(); var entity = new RsvpJpaEntity();
entity.setId(rsvp.getId()); entity.setId(rsvp.getId());

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -5,11 +5,13 @@ import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken; import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp; import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken; import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.in.CancelRsvpUseCase;
import de.fete.domain.port.in.CountAttendeesByEventUseCase; import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase; import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository; import de.fete.domain.port.out.RsvpRepository;
import jakarta.transaction.Transactional;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
@@ -18,7 +20,8 @@ import org.springframework.stereotype.Service;
/** Application service implementing RSVP operations. */ /** Application service implementing RSVP operations. */
@Service @Service
public class RsvpService public class RsvpService
implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase { implements CreateRsvpUseCase, CancelRsvpUseCase, CountAttendeesByEventUseCase,
GetAttendeesUseCase {
private final EventRepository eventRepository; private final EventRepository eventRepository;
private final RsvpRepository rsvpRepository; private final RsvpRepository rsvpRepository;
@@ -51,6 +54,14 @@ public class RsvpService
return rsvpRepository.save(rsvp); return rsvpRepository.save(rsvp);
} }
@Override
@Transactional
public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) {
eventRepository.findByEventToken(eventToken)
.ifPresent(event ->
rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken));
}
@Override @Override
public long countByEvent(EventToken eventToken) { public long countByEvent(EventToken eventToken) {
Event event = eventRepository.findByEventToken(eventToken) Event event = eventRepository.findByEventToken(eventToken)

View File

@@ -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
) {} ) {}

View File

@@ -0,0 +1,11 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.RsvpToken;
/** Inbound port for cancelling an RSVP. */
public interface CancelRsvpUseCase {
/** Cancels the RSVP identified by the given tokens. Idempotent — no error if not found. */
void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken);
}

View File

@@ -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();
} }

View File

@@ -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 de.fete.domain.model.RsvpToken;
import java.util.List; import java.util.List;
/** Outbound port for persisting and querying RSVPs. */ /** Outbound port for persisting and querying RSVPs. */
@@ -14,4 +15,7 @@ public interface RsvpRepository {
/** Finds all RSVPs for the given event, ordered by ID ascending. */ /** Finds all RSVPs for the given event, ordered by ID ascending. */
List<Rsvp> findByEventId(Long eventId); List<Rsvp> findByEventId(Long eventId);
/** Deletes an RSVP by event ID and RSVP token. Returns true if a record was deleted. */
boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken);
} }

View File

@@ -37,14 +37,46 @@ paths:
schema: schema:
$ref: "#/components/schemas/ValidationProblemDetail" $ref: "#/components/schemas/ValidationProblemDetail"
/events/{token}/rsvps: /events/{eventToken}/rsvps/{rsvpToken}:
delete:
operationId: cancelRsvp
summary: Cancel RSVP
description: |
Permanently deletes an RSVP identified by the RSVP token.
Idempotent: returns 204 whether the RSVP existed or not.
tags:
- events
parameters:
- name: eventToken
in: path
required: true
schema:
type: string
format: uuid
description: Event token (UUID)
- name: rsvpToken
in: path
required: true
schema:
type: string
format: uuid
description: RSVP token (UUID) identifying the attendance to cancel
responses:
"204":
description: >
RSVP successfully cancelled (or was already cancelled).
No response body.
"500":
description: Internal server error
/events/{eventToken}/rsvps:
post: post:
operationId: createRsvp operationId: createRsvp
summary: Submit an RSVP for an event summary: Submit an RSVP for an event
tags: tags:
- events - events
parameters: parameters:
- name: token - name: eventToken
in: path in: path
required: true required: true
schema: schema:
@@ -83,14 +115,14 @@ paths:
schema: schema:
$ref: "#/components/schemas/ProblemDetail" $ref: "#/components/schemas/ProblemDetail"
/events/{token}/attendees: /events/{eventToken}/attendees:
get: get:
operationId: getAttendees operationId: getAttendees
summary: Get attendee list for an event (organizer only) summary: Get attendee list for an event (organizer only)
tags: tags:
- events - events
parameters: parameters:
- name: token - name: eventToken
in: path in: path
required: true required: true
schema: schema:
@@ -124,14 +156,14 @@ paths:
schema: schema:
$ref: "#/components/schemas/ProblemDetail" $ref: "#/components/schemas/ProblemDetail"
/events/{token}: /events/{eventToken}:
get: get:
operationId: getEvent operationId: getEvent
summary: Get public event details by token summary: Get public event details by token
tags: tags:
- events - events
parameters: parameters:
- name: token - name: eventToken
in: path in: path
required: true required: true
schema: schema:
@@ -160,7 +192,6 @@ components:
- title - title
- dateTime - dateTime
- timezone - timezone
- expiryDate
properties: properties:
title: title:
type: string type: string
@@ -181,11 +212,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
@@ -195,7 +221,6 @@ components:
- title - title
- dateTime - dateTime
- timezone - timezone
- expiryDate
properties: properties:
eventToken: eventToken:
type: string type: string
@@ -218,10 +243,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
@@ -231,7 +252,6 @@ components:
- dateTime - dateTime
- timezone - timezone
- attendeeCount - attendeeCount
- expired
properties: properties:
eventToken: eventToken:
type: string type: string
@@ -264,10 +284,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

View File

@@ -1,6 +1,7 @@
package de.fete.adapter.in.web; package de.fete.adapter.in.web;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
@@ -55,8 +56,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 +67,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 +78,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 +90,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 +117,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 +136,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 +148,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 +169,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 +197,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 +221,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
@@ -493,6 +375,72 @@ class EventControllerIntegrationTest {
"application/problem+json")); "application/problem+json"));
} }
// --- Cancel RSVP tests ---
@Test
void cancelRsvpReturns204AndDeletesRow() throws Exception {
EventJpaEntity event = seedEvent(
"Cancel Event", null, "Europe/Berlin",
null, LocalDate.now().plusDays(30));
UUID rsvpToken = seedRsvpAndGetToken(event, "Departing Guest");
long countBefore = rsvpJpaRepository.count();
mockMvc.perform(delete("/api/events/" + event.getEventToken()
+ "/rsvps/" + rsvpToken))
.andExpect(status().isNoContent());
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore - 1);
assertThat(rsvpJpaRepository.findByRsvpToken(rsvpToken)).isEmpty();
}
@Test
void cancelRsvpReturns204WhenAlreadyDeleted() throws Exception {
EventJpaEntity event = seedEvent(
"Idempotent Event", null, "Europe/Berlin",
null, LocalDate.now().plusDays(30));
mockMvc.perform(delete("/api/events/" + event.getEventToken()
+ "/rsvps/" + UUID.randomUUID()))
.andExpect(status().isNoContent());
}
@Test
void cancelRsvpReturns204WhenEventNotFound() throws Exception {
mockMvc.perform(delete("/api/events/" + UUID.randomUUID()
+ "/rsvps/" + UUID.randomUUID()))
.andExpect(status().isNoContent());
}
@Test
void attendeeCountDecreasesAfterCancelRsvp() throws Exception {
EventJpaEntity event = seedEvent(
"Count Cancel Event", null, "Europe/Berlin",
null, LocalDate.now().plusDays(30));
UUID rsvpToken = seedRsvpAndGetToken(event, "Leaving Guest");
seedRsvp(event, "Staying Guest");
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(jsonPath("$.attendeeCount").value(2));
mockMvc.perform(delete("/api/events/" + event.getEventToken()
+ "/rsvps/" + rsvpToken))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/events/" + event.getEventToken()))
.andExpect(jsonPath("$.attendeeCount").value(1));
}
private UUID seedRsvpAndGetToken(EventJpaEntity event, String name) {
var rsvp = new RsvpJpaEntity();
UUID token = UUID.randomUUID();
rsvp.setRsvpToken(token);
rsvp.setEventId(event.getId());
rsvp.setName(name);
rsvpJpaRepository.save(rsvp);
return token;
}
private void seedRsvp(EventJpaEntity event, String name) { private void seedRsvp(EventJpaEntity event, String name) {
var rsvp = new RsvpJpaEntity(); var rsvp = new RsvpJpaEntity();
rsvp.setRsvpToken(UUID.randomUUID()); rsvp.setRsvpToken(UUID.randomUUID());

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -191,6 +191,41 @@ class RsvpServiceTest {
return rsvp; return rsvp;
} }
@Test
void cancelRsvpDeletesWhenEventAndRsvpExist() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
RsvpToken rsvpToken = RsvpToken.generate();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken)).thenReturn(true);
rsvpService.cancelRsvp(token, rsvpToken);
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.getId(), rsvpToken);
}
@Test
void cancelRsvpSucceedsWhenRsvpNotFound() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
RsvpToken rsvpToken = RsvpToken.generate();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken)).thenReturn(false);
rsvpService.cancelRsvp(token, rsvpToken);
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.getId(), rsvpToken);
}
@Test
void cancelRsvpSucceedsWhenEventNotFound() {
EventToken token = EventToken.generate();
RsvpToken rsvpToken = RsvpToken.generate();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty());
rsvpService.cancelRsvp(token, rsvpToken);
}
private Event buildActiveEvent() { private Event buildActiveEvent() {
var event = new Event(); var event = new Event();
event.setId(1L); event.setId(1L);

View File

@@ -0,0 +1,276 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
import type { StoredEvent } from '../src/composables/useEventStorage'
const STORAGE_KEY = 'fete:events'
const fullEvent = {
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
title: 'Summer BBQ',
description: 'Bring your own drinks!',
dateTime: '2026-03-15T20:00:00+01:00',
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
}
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
function seedEvents(events: StoredEvent[]): string {
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
}
function rsvpSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
rsvpToken,
rsvpName: 'Anna',
}
}
test.describe('US1: Cancel RSVP from Event Detail View', () => {
test('status bar shows cancel affordance when RSVP\'d', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Status bar visible
const statusBar = page.getByRole('button', { name: /You're attending/ })
await expect(statusBar).toBeVisible()
// Cancel button hidden initially
await expect(page.getByRole('button', { name: 'Cancel attendance' })).not.toBeVisible()
})
test('tapping status bar reveals cancel button', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Tap status bar
await page.getByRole('button', { name: /You're attending/ }).click()
// Cancel button appears
await expect(page.getByRole('button', { name: 'Cancel attendance' })).toBeVisible()
})
test('confirm cancellation → localStorage cleared, count decremented, bar reset', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Expand → Cancel attendance → Confirm in dialog
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
// Confirm dialog
await expect(page.getByText('Your attendance will be permanently cancelled.')).toBeVisible()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
// Bar resets to CTA state
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
await expect(page.getByText("You're attending!")).not.toBeVisible()
// Attendee count decremented
await expect(page.getByText('11 going')).toBeVisible()
// localStorage cleared
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
expect(event?.rsvpToken).toBeUndefined()
expect(event?.rsvpName).toBeUndefined()
})
test('server error → error message, state unchanged', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'fail' }, { status: 500 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Expand → Cancel → Confirm in dialog
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
// Error message
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
// Attendee count unchanged
await expect(page.getByText('12 going')).toBeVisible()
})
test('re-RSVP after cancel works', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
http.post('*/api/events/:token/rsvps', () => {
return HttpResponse.json(
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
{ status: 201 },
)
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Cancel first
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
// CTA should be back
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
// Re-RSVP
await page.getByRole('button', { name: "I'm attending" }).click()
const dialog = page.getByRole('dialog', { name: 'RSVP' })
await dialog.getByLabel('Your name').fill('Max')
await dialog.getByRole('button', { name: 'Count me in' }).click()
// Status bar returns
await expect(page.getByText("You're attending!")).toBeVisible()
})
})
test.describe('US2: Auto-Cancel on Event List Removal', () => {
test('removal of RSVP\'d event shows attendance warning in dialog', async ({ page }) => {
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
})
test('removal of non-RSVP\'d event shows standard dialog', async ({ page }) => {
const noRsvp: StoredEvent = {
eventToken: 'no-rsvp-token',
title: 'No RSVP Event',
dateTime: '2027-06-15T18:00:00Z',
organizerToken: 'org-123',
}
await page.addInitScript(seedEvents([noRsvp]))
await page.goto('/')
await page.getByRole('button', { name: /Remove No RSVP Event/ }).click()
await expect(page.getByText('This event will be removed from your list.')).toBeVisible()
await expect(page.getByText('attendance will be cancelled')).not.toBeVisible()
})
test('confirm removal → DELETE called → event removed from list', async ({ page, network }) => {
network.use(
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event gone
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
// localStorage updated
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
const found = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
expect(found).toBeUndefined()
})
test('server error on DELETE → error message, event stays in list', async ({ page, network }) => {
network.use(
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'fail' }, { status: 500 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event still in list
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
test('dismiss dialog → no changes', async ({ page }) => {
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Cancel' }).click()
// Event still there
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
})
test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
test('cancel from detail view with stale token (404) → treated as success', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'not found' }, { status: 404 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Cancel flow
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
// Treated as success — CTA returns
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
// localStorage cleaned
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
expect(event?.rsvpToken).toBeUndefined()
})
test('event list removal with stale token (404) → treated as success', async ({ page, network }) => {
network.use(
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'not found' }, { status: 404 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event removed from list
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
})
})

View File

@@ -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()

View File

@@ -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()
})
}) })

View File

@@ -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(

View File

@@ -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,
}) })
}), }),
) )

File diff suppressed because it is too large Load Diff

View File

@@ -38,17 +38,17 @@
"@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.54.0",
"eslint-plugin-vue": "~10.8.0", "eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"msw": "^2.12.10", "msw": "^2.12.10",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"oxlint": "~1.51.0", "oxlint": "~1.55.0",
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^8.0.0",
"vite-plugin-vue-devtools": "^8.0.6", "vite-plugin-vue-devtools": "^8.0.6",
"vitest": "^4.0.18", "vitest": "^4.0.18",
"vue-tsc": "^3.2.5" "vue-tsc": "^3.2.5"

View File

@@ -28,7 +28,7 @@
<ConfirmDialog <ConfirmDialog
:open="!!pendingDeleteToken" :open="!!pendingDeleteToken"
title="Remove event?" title="Remove event?"
message="This event will be removed from your list." :message="deleteDialogMessage"
confirm-label="Remove" confirm-label="Remove"
cancel-label="Cancel" cancel-label="Cancel"
@confirm="confirmDelete" @confirm="confirmDelete"
@@ -42,24 +42,62 @@ import { computed, ref } from 'vue'
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage' import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
import { useEventGrouping } from '../composables/useEventGrouping' import { useEventGrouping } from '../composables/useEventGrouping'
import { formatRelativeTime } from '../composables/useRelativeTime' import { formatRelativeTime } from '../composables/useRelativeTime'
import { api } from '../api/client'
import EventCard from './EventCard.vue' import EventCard from './EventCard.vue'
import SectionHeader from './SectionHeader.vue' import SectionHeader from './SectionHeader.vue'
import DateSubheader from './DateSubheader.vue' import DateSubheader from './DateSubheader.vue'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
import type { StoredEvent } from '../composables/useEventStorage' import type { StoredEvent } from '../composables/useEventStorage'
const { getStoredEvents, removeEvent } = useEventStorage() const { getStoredEvents, getRsvp, removeEvent } = useEventStorage()
const pendingDeleteToken = ref<string | null>(null) const pendingDeleteToken = ref<string | null>(null)
const deleteError = ref('')
const deleteDialogMessage = computed(() => {
if (!pendingDeleteToken.value) return ''
const rsvp = getRsvp(pendingDeleteToken.value)
if (rsvp) {
return 'This event will be removed from your list and your attendance will be cancelled.'
}
return 'This event will be removed from your list.'
})
function requestDelete(eventToken: string) { function requestDelete(eventToken: string) {
deleteError.value = ''
pendingDeleteToken.value = eventToken pendingDeleteToken.value = eventToken
} }
function confirmDelete() { async function confirmDelete() {
if (pendingDeleteToken.value) { if (!pendingDeleteToken.value) return
removeEvent(pendingDeleteToken.value)
const eventToken = pendingDeleteToken.value
const rsvp = getRsvp(eventToken)
if (rsvp) {
try {
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
params: {
path: {
eventToken: eventToken,
rsvpToken: rsvp.rsvpToken,
},
},
})
if (response.status !== 204 && response.status !== 404) {
deleteError.value = 'Could not cancel attendance. Please try again.'
pendingDeleteToken.value = null
return
} }
} catch {
deleteError.value = 'Could not cancel attendance. Please try again.'
pendingDeleteToken.value = null
return
}
}
removeEvent(eventToken)
pendingDeleteToken.value = null pendingDeleteToken.value = null
} }

View File

@@ -2,9 +2,32 @@
<div class="rsvp-bar"> <div class="rsvp-bar">
<div class="rsvp-bar__inner"> <div class="rsvp-bar__inner">
<!-- Status state: already RSVPed --> <!-- Status state: already RSVPed -->
<div v-if="hasRsvp" class="rsvp-bar__status"> <div v-if="hasRsvp" class="rsvp-bar__status-wrapper">
<div
class="rsvp-bar__status"
role="button"
tabindex="0"
:aria-expanded="expanded"
aria-label="You're attending. Tap to show cancel option."
@click="expanded = !expanded"
@keydown.enter.prevent="expanded = !expanded"
@keydown.space.prevent="expanded = !expanded"
@keydown.escape="expanded = false"
>
<span class="rsvp-bar__check" aria-hidden="true"></span> <span class="rsvp-bar__check" aria-hidden="true"></span>
<span class="rsvp-bar__text">You're attending!</span> <span class="rsvp-bar__text">You're attending!</span>
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true"></span>
</div>
<Transition name="rsvp-bar-cancel">
<button
v-if="expanded"
class="rsvp-bar__cancel"
type="button"
@click="$emit('cancel')"
>
Cancel attendance
</button>
</Transition>
</div> </div>
<!-- CTA state: no RSVP yet --> <!-- CTA state: no RSVP yet -->
@@ -18,13 +41,37 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ import { ref, watch } from 'vue'
const props = defineProps<{
hasRsvp?: boolean hasRsvp?: boolean
}>() }>()
defineEmits<{ defineEmits<{
open: [] open: []
cancel: []
}>() }>()
const expanded = ref(false)
watch(() => props.hasRsvp, () => {
expanded.value = false
})
function onClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.rsvp-bar__status-wrapper')) {
expanded.value = false
}
}
watch(expanded, (isExpanded) => {
if (isExpanded) {
document.addEventListener('click', onClickOutside, { capture: true })
} else {
document.removeEventListener('click', onClickOutside, { capture: true })
}
})
</script> </script>
<style scoped> <style scoped>
@@ -73,6 +120,12 @@ defineEmits<{
cursor: pointer; cursor: pointer;
} }
.rsvp-bar__status-wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.rsvp-bar__status { .rsvp-bar__status {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -88,6 +141,13 @@ defineEmits<{
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text-on-gradient); color: var(--color-text-on-gradient);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.rsvp-bar__status:hover {
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
} }
.rsvp-bar__check { .rsvp-bar__check {
@@ -101,4 +161,49 @@ defineEmits<{
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.rsvp-bar__chevron {
font-size: 1.2rem;
font-weight: 700;
transition: transform 0.2s ease;
transform: rotate(0deg);
margin-left: auto;
}
.rsvp-bar__chevron--open {
transform: rotate(90deg);
}
.rsvp-bar__cancel {
display: block;
width: 100%;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-card);
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
color: #ef5350;
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
cursor: pointer;
text-align: center;
transition: background 0.15s ease;
}
.rsvp-bar__cancel:hover {
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
}
.rsvp-bar-cancel-enter-active,
.rsvp-bar-cancel-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.rsvp-bar-cancel-enter-from,
.rsvp-bar-cancel-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style> </style>

View File

@@ -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', () => ({

View File

@@ -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,
} }
} }

View File

@@ -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,7 +209,6 @@ describe('isValidStoredEvent', () => {
eventToken: 'abc-123', eventToken: 'abc-123',
title: 'Birthday', title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00', dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}), }),
).toBe(true) ).toBe(true)
}) })

View File

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

View File

@@ -65,21 +65,6 @@
<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">
<label for="expiryDate" class="form-label">Expiry Date *</label>
<input
id="expiryDate"
v-model="form.expiryDate"
type="date"
class="form-field glass"
required
:min="tomorrow"
:aria-invalid="!!errors.expiryDate"
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
/>
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
</div>
<button type="submit" class="btn-primary glass" :disabled="submitting"> <button type="submit" class="btn-primary glass" :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 } })

View File

@@ -25,10 +25,6 @@
<!-- 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">
@@ -74,11 +70,28 @@
</div> </div>
</div> </div>
<!-- RSVP bar (only for loaded, non-expired events) --> <!-- Cancel error message -->
<div v-if="cancelError" class="detail__cancel-error" role="alert">
<p>{{ cancelError }}</p>
</div>
<!-- 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"
@cancel="confirmCancelOpen = true"
/>
<!-- Cancel confirmation dialog -->
<ConfirmDialog
:open="confirmCancelOpen"
title="Cancel attendance?"
message="Your attendance will be permanently cancelled."
confirm-label="Cancel attendance"
cancel-label="Keep"
@confirm="handleCancelRsvp"
@cancel="confirmCancelOpen = false"
/> />
<!-- RSVP bottom sheet --> <!-- RSVP bottom sheet -->
@@ -117,6 +130,7 @@ import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage' import { useEventStorage } from '@/composables/useEventStorage'
import AttendeeList from '@/components/AttendeeList.vue' import AttendeeList from '@/components/AttendeeList.vue'
import BottomSheet from '@/components/BottomSheet.vue' import BottomSheet from '@/components/BottomSheet.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import RsvpBar from '@/components/RsvpBar.vue' import RsvpBar from '@/components/RsvpBar.vue'
import type { components } from '@/api/schema' import type { components } from '@/api/schema'
@@ -124,7 +138,7 @@ type GetEventResponse = components['schemas']['GetEventResponse']
type State = 'loading' | 'loaded' | 'not-found' | 'error' type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute() const route = useRoute()
const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage() const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken } = useEventStorage()
const state = ref<State>('loading') const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null) const event = ref<GetEventResponse | null>(null)
@@ -136,6 +150,8 @@ const nameError = ref('')
const submitError = ref('') const submitError = ref('')
const submitting = ref(false) const submitting = ref(false)
const rsvpName = ref<string | undefined>(undefined) const rsvpName = ref<string | undefined>(undefined)
const confirmCancelOpen = ref(false)
const cancelError = ref('')
const isOrganizer = ref(false) const isOrganizer = ref(false)
const attendeeNames = ref<string[] | null>(null) const attendeeNames = ref<string[] | null>(null)
@@ -153,8 +169,8 @@ async function fetchEvent() {
event.value = null event.value = null
try { try {
const { data, error, response } = await api.GET('/events/{token}', { const { data, error, response } = await api.GET('/events/{eventToken}', {
params: { path: { token: route.params.eventToken as string } }, params: { path: { eventToken: route.params.eventToken as string } },
}) })
if (error) { if (error) {
@@ -201,8 +217,8 @@ async function submitRsvp() {
submitting.value = true submitting.value = true
try { try {
const { data, error } = await api.POST('/events/{token}/rsvps', { const { data, error } = await api.POST('/events/{eventToken}/rsvps', {
params: { path: { token: route.params.eventToken as string } }, params: { path: { eventToken: route.params.eventToken as string } },
body: { name: nameInput.value }, body: { name: nameInput.value },
}) })
@@ -232,11 +248,42 @@ async function submitRsvp() {
} }
} }
async function handleCancelRsvp() {
confirmCancelOpen.value = false
cancelError.value = ''
const stored = getRsvp(route.params.eventToken as string)
if (!stored) return
try {
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
params: {
path: {
eventToken: route.params.eventToken as string,
rsvpToken: stored.rsvpToken,
},
},
})
if (response.status === 204 || response.status === 404) {
removeRsvp(route.params.eventToken as string)
rsvpName.value = undefined
if (event.value) {
event.value.attendeeCount = Math.max(0, event.value.attendeeCount - 1)
}
} else {
cancelError.value = 'Could not cancel RSVP. Please try again.'
}
} catch {
cancelError.value = 'Could not cancel RSVP. Please try again.'
}
}
async function fetchAttendees(eventToken: string, organizerToken: string) { async function fetchAttendees(eventToken: string, organizerToken: string) {
try { try {
const { data, error } = await api.GET('/events/{token}/attendees', { const { data, error } = await api.GET('/events/{eventToken}/attendees', {
params: { params: {
path: { token: eventToken }, path: { eventToken: eventToken },
query: { organizerToken }, query: { organizerToken },
}, },
}) })
@@ -412,12 +459,6 @@ onMounted(fetchEvent)
text-align: center; text-align: center;
} }
.detail__banner--expired {
background: var(--color-glass);
color: var(--color-text-soft);
backdrop-filter: blur(4px);
}
/* Error / not-found message */ /* Error / not-found message */
.detail__message { .detail__message {
font-size: 1.1rem; font-size: 1.1rem;

View File

@@ -44,7 +44,6 @@ describe('EventCreateView', () => {
expect(wrapper.find('#description').exists()).toBe(true) expect(wrapper.find('#description').exists()).toBe(true)
expect(wrapper.find('#dateTime').exists()).toBe(true) expect(wrapper.find('#dateTime').exists()).toBe(true)
expect(wrapper.find('#location').exists()).toBe(true) expect(wrapper.find('#location').exists()).toBe(true)
expect(wrapper.find('#expiryDate').exists()).toBe(true)
}) })
it('has required attribute on required fields', async () => { it('has required attribute on required fields', async () => {
@@ -58,7 +57,6 @@ describe('EventCreateView', () => {
expect(wrapper.find('#title').attributes('required')).toBeDefined() expect(wrapper.find('#title').attributes('required')).toBeDefined()
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined() expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
}) })
it('does not have required attribute on optional fields', async () => { it('does not have required attribute on optional fields', async () => {
@@ -102,7 +100,6 @@ describe('EventCreateView', () => {
// Fill required fields // Fill required fields
await wrapper.find('#title').setValue('My Event') await wrapper.find('#title').setValue('My Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00') await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
@@ -127,7 +124,7 @@ describe('EventCreateView', () => {
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0) const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
expect(errorsBefore.length).toBeGreaterThanOrEqual(3) expect(errorsBefore.length).toBeGreaterThanOrEqual(2)
// Type into title field // Type into title field
await wrapper.find('#title').setValue('My Event') await wrapper.find('#title').setValue('My Event')
@@ -138,9 +135,6 @@ describe('EventCreateView', () => {
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')! const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(dateTimeError.textContent).not.toBe('') expect(dateTimeError.textContent).not.toBe('')
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(expiryError.textContent).not.toBe('')
}) })
it('shows validation errors when submitting empty form', async () => { it('shows validation errors when submitting empty form', async () => {
@@ -156,7 +150,7 @@ describe('EventCreateView', () => {
const errorElements = wrapper.findAll('[role="alert"]') const errorElements = wrapper.findAll('[role="alert"]')
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0) const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
expect(errorTexts.length).toBeGreaterThanOrEqual(3) expect(errorTexts.length).toBeGreaterThanOrEqual(2)
}) })
it('submits successfully, saves to storage, and navigates to event page', async () => { it('submits successfully, saves to storage, and navigates to event page', async () => {
@@ -169,6 +163,7 @@ describe('EventCreateView', () => {
getOrganizerToken: vi.fn(), getOrganizerToken: vi.fn(),
saveRsvp: vi.fn(), saveRsvp: vi.fn(),
getRsvp: vi.fn(), getRsvp: vi.fn(),
removeRsvp: vi.fn(),
removeEvent: vi.fn(), removeEvent: vi.fn(),
}) })
@@ -179,7 +174,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 +192,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 +201,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 +209,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 +236,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 +246,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)
}) })
}) })

View File

@@ -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()
@@ -315,8 +280,8 @@ describe('EventDetailView', () => {
await flushPromises() await flushPromises()
// Verify API call // Verify API call
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{token}/rsvps', { expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{eventToken}/rsvps', {
params: { path: { token: 'test-token' } }, params: { path: { eventToken: 'test-token' } },
body: { name: 'Max' }, body: { name: 'Max' },
}) })

View 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`.

View 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.

View 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).

View 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.

View 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.

View 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 (T002T007)
- 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)

View File

@@ -0,0 +1,36 @@
# Specification Quality Checklist: Cancel RSVP
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- The "Design Proposals" section is intentionally included as requested by the user — these are UX options, not implementation details.
- One decision point remains: which design option (A, B, or C) for the cancel UI. This is deferred to discussion with the user rather than marked as [NEEDS CLARIFICATION] since the user explicitly requested proposals.

View File

@@ -0,0 +1,40 @@
# Cancel RSVP — API Contract
# This contract will be merged into backend/src/main/resources/openapi/api.yaml
# Path: /events/{token}/rsvps/{rsvpToken}
# Method: DELETE
path:
/events/{token}/rsvps/{rsvpToken}:
delete:
summary: Cancel RSVP
description: |
Permanently deletes an RSVP identified by the RSVP token.
Idempotent: returns 204 whether the RSVP existed or not.
operationId: cancelRsvp
tags:
- Events
parameters:
- name: token
in: path
required: true
description: Event token (UUID)
schema:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440000"
- name: rsvpToken
in: path
required: true
description: RSVP token (UUID) identifying the attendance to cancel
schema:
type: string
format: uuid
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
responses:
"204":
description: >
RSVP successfully cancelled (or was already cancelled).
No response body.
"500":
description: Internal server error

View File

@@ -0,0 +1,74 @@
# Data Model: Cancel RSVP
**Feature**: 014-cancel-rsvp | **Date**: 2026-03-09
## Entities
### RSVP (existing — no schema changes)
| Field | Type | Constraints | Notes |
|-------|------|-------------|-------|
| id | BIGSERIAL | PK, auto-generated | Internal DB ID, never exposed |
| rsvp_token | UUID | UNIQUE, NOT NULL | Bearer credential for the guest |
| event_id | BIGINT | FK → events.id, NOT NULL | Links RSVP to event |
| name | VARCHAR(100) | NOT NULL | Guest display name |
**No migration needed.** The cancel feature only deletes existing rows — no new columns or tables.
## State Transitions
```
┌──────────┐ POST /events/{token}/rsvps ┌──────────┐
│ No RSVP │ ──────────────────────────────► │ Attending │
│ (guest) │ ◄────────────────────────────── │ (guest) │
└──────────┘ DELETE /events/{token}/rsvps/ └──────────┘
{rsvpToken}
```
The transition is fully reversible: after cancellation, the guest can create a new RSVP (new token generated).
## Client-Side Storage (localStorage)
### StoredEvent (existing interface — no changes)
```typescript
interface StoredEvent {
eventToken: string // always present
title: string // always present
dateTime: string // always present
organizerToken?: string // present if organizer
rsvpToken?: string // present if RSVP'd — CLEARED on cancel
rsvpName?: string // present if RSVP'd — CLEARED on cancel
}
```
### Cancel Effects on localStorage
| Action | rsvpToken | rsvpName | Event in list |
|--------|-----------|----------|---------------|
| Cancel from detail view | Removed | Removed | Kept |
| Remove from event list | Removed | Removed | Removed |
## Repository Changes
### RsvpRepository (domain port)
New method:
```java
/**
* Delete an RSVP by event ID and RSVP token.
* @return true if a record was deleted, false if not found
*/
boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken);
```
### RsvpJpaRepository
New method:
```java
long deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken);
```
Returns count of deleted rows (0 or 1). Spring Data JPA auto-implements this derived query.

View File

@@ -0,0 +1,90 @@
# Implementation Plan: Cancel RSVP
**Branch**: `014-cancel-rsvp` | **Date**: 2026-03-09 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/014-cancel-rsvp/spec.md`
## Summary
Allow guests to cancel their RSVP either explicitly from the event detail view (tap-to-reveal pattern on the RSVP bar) or implicitly when removing an RSVP'd event from their event list. The backend provides an idempotent DELETE endpoint; the frontend handles confirmation, API call, localStorage cleanup, and UI state reset.
## Technical Context
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
**Testing**: JUnit + Mockito (backend unit), MockMvc + Testcontainers (backend integration), Vitest (frontend unit), Playwright + MSW (frontend E2E)
**Target Platform**: Self-hosted PWA (web browser, mobile-first)
**Project Type**: Web application (hexagonal backend + SPA frontend)
**Performance Goals**: Cancel operation < 500ms server-side
**Constraints**: Privacy by design (no analytics), token-based auth (no login), idempotent delete
**Scale/Scope**: Single new endpoint, 3 modified frontend components, 1 new composable method
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Privacy by Design | PASS | No new PII stored. Delete operation removes data. No analytics. |
| II. Test-Driven Methodology | PASS | Plan includes TDD cycle: tests before implementation. E2E mandatory. |
| III. API-First Development | PASS | OpenAPI spec updated first, types generated before implementation. |
| IV. Simplicity & Quality | PASS | Minimal changes to existing code. No new abstractions. Idempotent delete is the simplest correct approach. |
| V. Dependency Discipline | PASS | No new dependencies required. |
| VI. Accessibility | PASS | Interactive elements will use semantic HTML, ARIA, keyboard navigation. Confirm dialog already accessible. |
**Gate result**: ALL PASS — proceed to Phase 0.
## Project Structure
### Documentation (this feature)
```text
specs/014-cancel-rsvp/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── cancel-rsvp.yaml # DELETE endpoint contract
└── tasks.md # Phase 2 output (by /speckit.tasks)
```
### Source Code (repository root)
```text
backend/
├── src/main/java/de/fete/
│ ├── domain/
│ │ ├── model/Rsvp.java # existing (no changes)
│ │ ├── model/RsvpToken.java # existing (no changes)
│ │ └── port/
│ │ ├── in/CancelRsvpUseCase.java # NEW use case port
│ │ └── out/RsvpRepository.java # MODIFY (add deleteByRsvpToken)
│ ├── application/service/RsvpService.java # MODIFY (implement cancel)
│ └── adapter/
│ ├── in/web/EventController.java # MODIFY (add DELETE handler)
│ └── out/persistence/
│ ├── RsvpJpaRepository.java # MODIFY (add deleteByRsvpToken)
│ └── RsvpPersistenceAdapter.java # MODIFY (implement delete)
├── src/main/resources/openapi/api.yaml # MODIFY (add DELETE endpoint)
└── src/test/java/de/fete/
├── application/service/RsvpServiceTest.java # MODIFY (add cancel tests)
└── adapter/in/web/EventControllerIntegrationTest.java # MODIFY (add cancel tests)
frontend/
├── src/
│ ├── api/schema.d.ts # REGENERATE (from updated OpenAPI)
│ ├── components/
│ │ ├── RsvpBar.vue # MODIFY (tap-to-reveal cancel)
│ │ ├── EventList.vue # MODIFY (conditional dialog msg)
│ │ └── ConfirmDialog.vue # existing (no changes)
│ ├── composables/useEventStorage.ts # MODIFY (add removeRsvp)
│ └── views/EventDetailView.vue # MODIFY (add cancel logic)
└── e2e/
└── cancel-rsvp.spec.ts # NEW (E2E tests)
```
**Structure Decision**: Follows existing hexagonal architecture. New use case port + implementation in existing service. Single new endpoint added to existing controller.
## Complexity Tracking
No constitution violations — table not needed.

View File

@@ -0,0 +1,57 @@
# Quickstart: Cancel RSVP
**Feature**: 014-cancel-rsvp | **Date**: 2026-03-09
## Implementation Order
1. **OpenAPI spec** — Add DELETE endpoint to `api.yaml`
2. **Regenerate types** — Backend (Maven generate) + Frontend (`npm run generate:api`)
3. **Backend TDD** — Write tests → implement repository → service → controller
4. **Frontend composable** — Add `removeRsvp()` to `useEventStorage.ts`
5. **Frontend US-1** — RsvpBar tap-to-reveal + EventDetailView cancel logic
6. **Frontend US-2** — EventList conditional dialog + server-side cancel before removal
7. **Frontend US-3** — Edge case handling (stale tokens treated as success)
8. **E2E tests** — Playwright tests for all three user stories
## Key Commands
```bash
# Backend
cd backend && ./mvnw test # Run backend tests
cd backend && ./mvnw verify # Full verify (checkstyle + tests)
cd backend && ./mvnw spring-boot:run # Run backend locally
# Frontend
cd frontend && npm run generate:api # Regenerate types from OpenAPI
cd frontend && npm run test:unit # Run unit tests
cd frontend && npx playwright test # Run E2E tests
cd frontend && npm run dev # Dev server
# Both
cd backend && ./mvnw test && cd ../frontend && npm run test:unit # Quick check
```
## Key Files to Modify
| File | Change |
|------|--------|
| `backend/src/main/resources/openapi/api.yaml` | Add DELETE `/events/{token}/rsvps/{rsvpToken}` |
| `backend/.../port/in/CancelRsvpUseCase.java` | New use case interface |
| `backend/.../port/out/RsvpRepository.java` | Add `deleteByEventIdAndRsvpToken()` |
| `backend/.../RsvpJpaRepository.java` | Add derived delete query |
| `backend/.../RsvpPersistenceAdapter.java` | Implement delete |
| `backend/.../RsvpService.java` | Implement `CancelRsvpUseCase` |
| `backend/.../EventController.java` | Add `cancelRsvp()` handler |
| `frontend/src/composables/useEventStorage.ts` | Add `removeRsvp()` |
| `frontend/src/components/RsvpBar.vue` | Tap-to-reveal cancel button |
| `frontend/src/views/EventDetailView.vue` | Cancel logic + confirm dialog |
| `frontend/src/components/EventList.vue` | Conditional dialog message + server cancel |
| `frontend/e2e/cancel-rsvp.spec.ts` | E2E tests for all scenarios |
## Gotchas
- The JPA `deleteBy...` method requires `@Transactional` on the calling service method.
- The `@Transactional` import must be `jakarta.transaction.Transactional` (not Spring's).
- After updating OpenAPI spec, run `npm run generate:api` in frontend AND Maven generate-sources in backend.
- The RsvpBar component currently has no click handler on the status state — this needs to be added carefully with proper accessibility (role, aria-expanded, keyboard support).
- The EventList's `confirmDelete` currently calls `removeEvent()` synchronously — it needs to become async for the server call.

View File

@@ -0,0 +1,82 @@
# Research: Cancel RSVP
**Feature**: 014-cancel-rsvp | **Date**: 2026-03-09
## 1. Idempotent DELETE Semantics
**Decision**: Return 204 No Content for both successful deletion and "already deleted" cases.
**Rationale**: HTTP DELETE is defined as idempotent (RFC 9110 §9.3.5). Returning 204 regardless of whether the RSVP existed simplifies client logic — the client doesn't need to distinguish "deleted now" from "was already gone." This directly satisfies FR-002 and US-3.
**Alternatives considered**:
- Return 404 for "not found" RSVP: Violates idempotency expectations, forces client to handle two success paths.
- Return 200 with body: Unnecessary — no useful information to return after deletion.
## 2. Authorization Model for Delete
**Decision**: DELETE requires both event token (path) and RSVP token (path). The RSVP token acts as a bearer credential — possession equals authorization.
**Rationale**: Consistent with the existing privacy model. The RSVP token is a UUID v4 generated server-side, unguessable. No additional auth needed. The event token scopes the operation to prevent cross-event token collision (defense in depth).
**Alternatives considered**:
- RSVP token only: Sufficient in practice (UUIDs are globally unique) but loses the event context in the URL, making the API less RESTful.
- Require organizer token: Would prevent guests from self-cancelling — contradicts the spec.
## 3. Backend Delete Implementation Pattern
**Decision**: Use `deleteByRsvpToken(UUID)` on the JPA repository. Return the count of deleted rows (0 or 1) to determine if a record was actually removed (needed for attendee count response).
**Rationale**: Spring Data JPA supports `deleteBy...` derived queries returning `long` (count of deleted rows). This is a single query, no need to fetch-then-delete. The existing `findByRsvpToken()` method confirms the naming convention.
**Alternatives considered**:
- `findByRsvpToken()` then `delete(entity)`: Two queries instead of one. Unnecessary.
- Native `@Query` DELETE: Overkill for a simple single-column delete.
## 4. Backend Validation: Event Token Check
**Decision**: Validate that the RSVP belongs to the specified event before deleting. If the RSVP token exists but belongs to a different event, return 404.
**Rationale**: Defense in depth. Prevents accidental or malicious cross-event RSVP deletion via URL manipulation. The combined lookup (`findByEventIdAndRsvpToken`) is a single indexed query.
**Alternatives considered**:
- Skip event validation: Simpler but allows deleting RSVPs via wrong event URLs. Minor security concern but violates principle of least surprise.
## 5. Frontend: Tap-to-Reveal Pattern for Cancel
**Decision**: The "You're attending!" bar becomes tappable. Tapping reveals a slide-out "Cancel attendance" button. Tapping outside or pressing Escape collapses it. A subtle chevron/icon hints at interactivity.
**Rationale**: Specified in the feature spec's design decision. Prevents accidental cancellation (two-step: reveal + confirm dialog). Keeps the default state clean and positive.
**Alternatives considered**:
- Always-visible cancel button: Too prominent, encourages cancellation over attendance.
- Long-press to reveal: Not discoverable, no established mobile convention for this.
- Swipe gesture: Already used for event list deletion — would create gesture ambiguity.
## 6. Frontend: removeRsvp vs removeEvent in localStorage
**Decision**: Add `removeRsvp(eventToken)` method to `useEventStorage.ts` that clears only `rsvpToken` and `rsvpName` from a stored event, keeping the event itself in the list.
**Rationale**: Cancel from event detail view should NOT remove the event from the list — the guest may still want to see event details. Only the RSVP fields need clearing. The existing `removeEvent()` method is used for event list removal (US-2).
**Alternatives considered**:
- Reuse `removeEvent()` for both: Would remove the event from the list when cancelling from detail view — unexpected behavior.
## 7. Frontend: Event List Removal with RSVP
**Decision**: When removing an event that has an RSVP token, call DELETE on the server FIRST. On success (or 404), then remove from localStorage. On server error, show error and keep the event.
**Rationale**: Server-first ensures data consistency (FR-007). If we removed from localStorage first and the server call failed, the RSVP would remain on the server with no way for the guest to cancel it.
**Alternatives considered**:
- Optimistic removal (remove from localStorage, fire-and-forget server call): Risks data inconsistency if server call fails.
- Remove from localStorage first, retry server call: Complex retry logic, still risks inconsistency.
## 8. Attendee Count After Cancellation
**Decision**: After successful DELETE, decrement the local attendee count by 1 in the event detail view. Do not re-fetch the event.
**Rationale**: Avoids an extra GET request. The count is deterministic — if the delete succeeded, exactly one attendee was removed. The same pattern is used for RSVP creation (increment by 1).
**Alternatives considered**:
- Re-fetch event data: Extra network request, slower UX, unnecessary.
- Return updated count from DELETE endpoint: Adds response body to a 204 — semantically wrong.

View File

@@ -0,0 +1,114 @@
# Feature Specification: Cancel RSVP
**Feature Branch**: `014-cancel-rsvp`
**Created**: 2026-03-09
**Status**: Draft
**Input**: User description: "A guest can cancel their attendance if they still have their RSVP token in localStorage. The event detail view should offer this functionality (design proposals needed). The RSVP is permanently deleted from the database by RSVP token. When a guest removes an entry from their event list, attendance is automatically cancelled. The confirmation dialog informs the guest about this behavior."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Cancel RSVP from Event Detail View (Priority: P1)
A guest who previously RSVP'd to an event visits the event detail page. The sticky bottom bar shows their attendance status ("You're attending!"). The guest can cancel their attendance directly from this view. After cancellation, their RSVP is permanently removed from the server and from localStorage. The attendee count decreases by one, and the RSVP bar returns to the initial "I'm attending" call-to-action state, allowing the guest to re-RSVP if desired.
**Why this priority**: This is the core feature — the explicit, intentional cancellation flow. It gives guests direct control over their attendance and is the primary interaction point.
**Independent Test**: Can be fully tested by navigating to an event detail page as an RSVP'd guest, cancelling, and verifying the RSVP is deleted server-side and the UI resets.
**Acceptance Scenarios**:
1. **Given** a guest has an RSVP token stored in localStorage for an event, **When** they view the event detail page, **Then** they see a way to cancel their attendance alongside their attendance status.
2. **Given** the guest initiates cancellation, **When** the system presents a confirmation prompt, **Then** the prompt clearly states that attendance will be permanently cancelled.
3. **Given** the guest confirms cancellation, **When** the server successfully deletes the RSVP, **Then** the RSVP token and name are removed from localStorage, the attendee count decreases by one, and the RSVP bar returns to the initial call-to-action state.
4. **Given** the guest confirms cancellation, **When** the server returns an error, **Then** the guest sees an error message and their attendance status remains unchanged.
5. **Given** the guest cancels and the RSVP bar resets, **When** the guest taps the call-to-action, **Then** they can submit a new RSVP as normal.
---
### User Story 2 - Auto-Cancel on Event List Removal (Priority: P2)
A guest removes an event from their personal event list (via delete button or swipe gesture on the event card). If the guest has an RSVP token for that event, the confirmation dialog informs them that removing the event will also cancel their attendance on the server. Upon confirmation, the RSVP is deleted from the server before the event is removed from localStorage.
**Why this priority**: This ensures data consistency between client and server. Without it, a guest could believe they cancelled but their name would remain on the attendee list.
**Independent Test**: Can be fully tested by adding an RSVP'd event to the list, removing it via the event list, and verifying the RSVP is deleted server-side.
**Acceptance Scenarios**:
1. **Given** a guest has an event in their list with an RSVP token, **When** they initiate removal (delete button or swipe), **Then** the confirmation dialog explicitly mentions that their attendance will also be cancelled.
2. **Given** a guest has an event in their list without an RSVP token (organizer-only or link-only), **When** they initiate removal, **Then** the confirmation dialog does not mention attendance cancellation (existing behavior unchanged).
3. **Given** the guest confirms removal of an RSVP'd event, **When** the server successfully deletes the RSVP, **Then** the event is removed from localStorage (including RSVP token) and disappears from the list.
4. **Given** the guest confirms removal of an RSVP'd event, **When** the server fails to delete the RSVP, **Then** the guest sees an error message and the event remains in the list unchanged.
5. **Given** the guest dismisses the confirmation dialog, **When** no action is taken, **Then** the event and RSVP remain unchanged.
---
### User Story 3 - Cancel RSVP with Expired/Invalid Token (Priority: P3)
A guest attempts to cancel their RSVP, but the token is no longer valid on the server (e.g., the event was deleted, or the RSVP was already removed by another means). The system handles this gracefully.
**Why this priority**: Edge case handling — less common but important for a smooth user experience.
**Independent Test**: Can be tested by manipulating localStorage to contain a stale RSVP token and attempting cancellation.
**Acceptance Scenarios**:
1. **Given** a guest has a stale RSVP token in localStorage, **When** they attempt to cancel from the event detail view, **Then** the system treats a "not found" server response as a successful cancellation (the RSVP is already gone), cleans up localStorage, and resets the UI.
2. **Given** a guest has a stale RSVP token in localStorage, **When** they remove the event from their list, **Then** the system treats a "not found" server response as success and removes the event from localStorage.
---
### Edge Cases
- What happens when the guest has no internet connection during cancellation? → Show an error message; do not modify localStorage or UI state.
- What happens if the event itself has been deleted? → The event detail view already handles the "not found" state. For list removal, treat the 404 as success and clean up localStorage.
- What happens if multiple browser tabs are open? → localStorage changes propagate across tabs; the RSVP bar should reflect the current localStorage state on visibility/focus.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST provide a cancellation endpoint that permanently deletes an RSVP record identified by event token and RSVP token.
- **FR-002**: System MUST return a success response when the RSVP is deleted, including when the RSVP does not exist (idempotent delete).
- **FR-003**: The event detail view MUST display a cancel option when the guest has an RSVP token in localStorage for the current event.
- **FR-004**: The cancel option MUST require explicit confirmation before proceeding.
- **FR-005**: After successful cancellation, the system MUST remove the RSVP token and RSVP name from localStorage for that event.
- **FR-006**: After successful cancellation on the event detail view, the attendee count MUST decrease by one and the RSVP bar MUST return to its initial call-to-action state.
- **FR-007**: When a guest removes an RSVP'd event from their event list, the system MUST attempt to delete the RSVP on the server before removing it from localStorage.
- **FR-008**: The event list removal confirmation dialog MUST inform the guest that their attendance will be cancelled when an RSVP token is present.
- **FR-009**: If the server returns an error (other than "not found") during cancellation, the system MUST show an error message and leave the local state unchanged.
- **FR-010**: The cancellation endpoint MUST only delete the RSVP matching the provided RSVP token — no other RSVPs or event data may be affected.
### Key Entities
- **RSVP**: An attendance record linking a guest name to an event. Identified by a unique RSVP token (UUID). Existence indicates attendance; deletion indicates cancellation.
- **Stored Event (client-side)**: A localStorage entry containing event metadata, and optionally an RSVP token and name if the guest has RSVP'd.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A guest can cancel their RSVP from the event detail view in under 5 seconds (two taps: cancel + confirm).
- **SC-002**: After cancellation, the guest's name no longer appears in the attendee list when viewed by the organizer.
- **SC-003**: Removing an RSVP'd event from the event list results in server-side RSVP deletion 100% of the time when the server is reachable.
- **SC-004**: The confirmation dialog clearly communicates the consequence (attendance cancellation) — no guest should be surprised by the side effect.
- **SC-005**: A guest can re-RSVP after cancellation without any issues.
## Design Decision: Cancel UI on Event Detail View
**Chosen**: Tap-to-Reveal Pattern
The current RSVP bar (sticky bottom) shows "You're attending!" after an RSVP. The status bar becomes tappable. Tapping it reveals a slide-out or expand animation with a "Cancel attendance" button. Tapping outside collapses it back. A subtle visual hint (chevron or icon) indicates the bar is interactive.
## Assumptions
- The existing `findByRsvpToken()` repository method can be leveraged for the delete operation.
- The RSVP token alone (combined with the event token in the URL) is sufficient authorization for deletion — consistent with the project's token-based privacy model.
- The delete operation is idempotent: deleting an already-deleted RSVP returns success (not an error).
- The event list confirmation dialog already exists and can be extended with conditional messaging.
## Dependencies
- **008-rsvp**: The RSVP creation flow and localStorage storage pattern (completed).
- **007-view-event**: The event detail view and RSVP bar component (completed).
- **009-list-events**: The event list with delete functionality (completed).

View File

@@ -0,0 +1,206 @@
# Tasks: Cancel RSVP
**Input**: Design documents from `/specs/014-cancel-rsvp/`
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/
**Tests**: Included — constitution mandates TDD (Red → Green → Refactor). E2E tests mandatory per principle II.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Setup (API Contract & Type Generation)
**Purpose**: Define the DELETE endpoint contract and regenerate types for both backend and frontend.
- [x] T001 Add DELETE `/events/{token}/rsvps/{rsvpToken}` endpoint to `backend/src/main/resources/openapi/api.yaml` per `specs/014-cancel-rsvp/contracts/cancel-rsvp.yaml` — operationId `cancelRsvp`, responses 204 (success/idempotent) and 500
- [x] T002 Regenerate backend API interfaces from updated OpenAPI spec via `cd backend && ./mvnw generate-sources`
- [x] T003 Regenerate frontend TypeScript types from updated OpenAPI spec via `cd frontend && npm run generate:api`
---
## Phase 2: Foundational (Backend Delete Infrastructure)
**Purpose**: Backend repository and service layer for RSVP deletion — blocks all user stories.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
### Tests (write FIRST, must FAIL before implementation)
- [x] T004 [P] Write unit test for `cancelRsvp()` in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java` — test cases: successful delete (returns true), RSVP not found (returns false), event not found (returns false)
- [x] T005 [P] Write integration test for `DELETE /api/events/{token}/rsvps/{rsvpToken}` in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java` — test cases: 204 on successful delete, 204 on already-deleted RSVP (idempotent), 204 when event not found (idempotent), verify RSVP row actually removed from DB
### Implementation
- [x] T006 Create `CancelRsvpUseCase` interface in `backend/src/main/java/de/fete/domain/port/in/CancelRsvpUseCase.java` — single method `cancelRsvp(EventToken, RsvpToken)` returning void
- [x] T007 Add `deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken)` to domain port `backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java`
- [x] T008 Add `deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken)` derived delete query to `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java`
- [x] T009 Implement `deleteByEventIdAndRsvpToken()` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java` — delegate to JPA repository, return boolean (deleted count > 0)
- [x] T010 Implement `CancelRsvpUseCase` in `backend/src/main/java/de/fete/application/service/RsvpService.java` — look up event by token, if found call repository delete, no error on not-found (idempotent). Add `@Transactional`
- [x] T011 Implement `cancelRsvp()` handler in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — accept event token and RSVP token path params, call use case, return 204 No Content
- [x] T012 Run `cd backend && ./mvnw verify` — all tests (existing + new) must pass
**Checkpoint**: Backend DELETE endpoint functional and tested. Verify via integration tests.
---
## Phase 3: User Story 1 — Cancel RSVP from Event Detail View (Priority: P1) 🎯 MVP
**Goal**: Guest can tap the "You're attending!" bar to reveal a cancel button, confirm cancellation, and have RSVP deleted server-side with UI reset.
**Independent Test**: Navigate to event detail as RSVP'd guest → tap status bar → tap cancel → confirm → verify RSVP deleted, attendee count decremented, bar reset to CTA state. Then re-RSVP to verify flow works again.
### Tests (write FIRST, must FAIL before implementation)
- [x] T013 [US1] Write E2E test file `frontend/e2e/cancel-rsvp.spec.ts` — US1 scenarios: (1) status bar shows cancel affordance when RSVP'd, (2) tap reveals cancel button, (3) confirm cancellation → 204 → localStorage cleared + count decremented + bar reset, (4) server error → error message + state unchanged, (5) re-RSVP after cancel works
### Implementation
- [x] T014 [US1] Add `removeRsvp(eventToken: string)` method to `frontend/src/composables/useEventStorage.ts` — clears `rsvpToken` and `rsvpName` from the stored event without removing the event from the list
- [x] T015 [US1] Modify `frontend/src/components/RsvpBar.vue` — make status state tappable with tap-to-reveal pattern: (1) add `expanded` state, (2) tapping "You're attending!" toggles expanded, (3) expanded state shows "Cancel attendance" button, (4) emit `cancel` event on button click, (5) collapse on outside click/Escape, (6) add subtle chevron icon hint, (7) ARIA: `role="button"`, `aria-expanded`, keyboard support (Enter/Space to toggle, Escape to collapse)
- [x] T016 [US1] Add cancel RSVP logic to `frontend/src/views/EventDetailView.vue` — (1) handle `cancel` emit from RsvpBar, (2) show ConfirmDialog with message "Your attendance will be permanently cancelled.", (3) on confirm: call `api.DELETE('/events/{token}/rsvps/{rsvpToken}')`, (4) on 204: call `removeRsvp()`, decrement attendee count, reset RSVP state (`rsvpName = ''`), (5) on error: show error message "Could not cancel RSVP. Please try again.", (6) keep local state unchanged on error
- [x] T017 [US1] Run frontend unit tests `cd frontend && npm run test:unit` and E2E tests `cd frontend && npx playwright test cancel-rsvp` — all must pass
**Checkpoint**: US1 fully functional. Guest can cancel RSVP from event detail view and re-RSVP.
---
## Phase 4: User Story 2 — Auto-Cancel on Event List Removal (Priority: P2)
**Goal**: When a guest removes an RSVP'd event from their event list, the confirmation dialog warns about attendance cancellation and the RSVP is deleted server-side before localStorage cleanup.
**Independent Test**: Add RSVP'd event to list → initiate removal → verify dialog mentions attendance → confirm → verify server DELETE called → event removed from list. Also test: event without RSVP shows standard dialog (no mention of attendance).
### Tests (write FIRST, must FAIL before implementation)
- [x] T018 [US2] Add US2 E2E scenarios to `frontend/e2e/cancel-rsvp.spec.ts` — (1) removal of RSVP'd event shows "attendance will be cancelled" in dialog, (2) removal of non-RSVP'd event shows standard dialog (no attendance mention), (3) confirm removal → DELETE called → event removed from list, (4) server error on DELETE → error message + event stays in list, (5) dismiss dialog → no changes
### Implementation
- [x] T019 [US2] Modify `frontend/src/components/EventList.vue` — (1) detect if pending-delete event has RSVP token via `getRsvp(eventToken)`, (2) set conditional dialog message: with RSVP → "This event will be removed from your list and your attendance will be cancelled." / without RSVP → existing message, (3) make `confirmDelete()` async: if RSVP exists, call `api.DELETE('/events/{token}/rsvps/{rsvpToken}')` first, (4) on success or 404: proceed with `removeEvent()`, (5) on other error: show error message, abort removal
- [x] T020 [US2] Import API client and `getRsvp` from `useEventStorage` in `frontend/src/components/EventList.vue` — ensure API client is available for server calls
- [x] T021 [US2] Run E2E tests `cd frontend && npx playwright test cancel-rsvp` — all US1 + US2 scenarios must pass
**Checkpoint**: US1 + US2 functional. Both cancel paths work independently.
---
## Phase 5: User Story 3 — Cancel RSVP with Expired/Invalid Token (Priority: P3)
**Goal**: Gracefully handle stale RSVP tokens — treat "not found" server responses as successful cancellation.
**Independent Test**: Set stale RSVP token in localStorage → attempt cancel from detail view → verify 404 treated as success → localStorage cleaned. Same for event list removal.
### Tests (write FIRST, must FAIL before implementation)
- [x] T022 [US3] Add US3 E2E scenarios to `frontend/e2e/cancel-rsvp.spec.ts` — (1) cancel from detail view with stale token (server 404) → treated as success, localStorage cleaned, UI reset, (2) event list removal with stale token (server 404) → treated as success, event removed
### Implementation
- [x] T023 (already implemented in T016 — 404 treated as success) [US3] Update cancel logic in `frontend/src/views/EventDetailView.vue` — treat 404 response from DELETE as success (RSVP already gone): clean up localStorage and reset UI same as 204
- [x] T024 (already implemented in T019 — 404 treated as success) [US3] Update cancel logic in `frontend/src/components/EventList.vue` — treat 404 response from DELETE as success: proceed with `removeEvent()` (note: this may already be handled if T019 implemented "success or 404" correctly — verify and adjust if needed)
- [x] T025 [US3] Run all E2E tests `cd frontend && npx playwright test cancel-rsvp` — all US1 + US2 + US3 scenarios must pass
**Checkpoint**: All user stories functional. Edge cases handled gracefully.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final verification and cleanup across all stories.
- [x] T026 Run full backend verify `cd backend && ./mvnw verify` — checkstyle + all tests
- [x] T027 Run full frontend test suite `cd frontend && npm run test:unit && npx playwright test` — all unit + E2E tests
- [x] T028 Verify accessibility: RsvpBar cancel interaction is keyboard-navigable (Tab, Enter/Space, Escape), ARIA attributes correct, confirm dialog focus management works
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 (generated API interfaces needed)
- **US1 (Phase 3)**: Depends on Phase 2 (backend endpoint must exist)
- **US2 (Phase 4)**: Depends on Phase 2 (backend endpoint must exist). Independent of US1.
- **US3 (Phase 5)**: Depends on US1 and US2 (refines their error handling)
- **Polish (Phase 6)**: Depends on all stories complete
### User Story Dependencies
- **US1 (P1)**: Independent after Phase 2 — core cancel flow
- **US2 (P2)**: Independent after Phase 2 — can be implemented in parallel with US1
- **US3 (P3)**: Depends on US1 + US2 — refines error handling in both
### Within Each User Story
- Tests MUST be written and FAIL before implementation
- Composable/model changes before component changes
- Component changes before view integration
- Story complete before moving to next priority
### Parallel Opportunities
- T004 + T005 (backend tests) can run in parallel
- T006 + T007 (use case + repository port) can run in parallel
- US1 and US2 can be implemented in parallel after Phase 2 (different files)
- T026 + T027 (backend verify + frontend tests) can run in parallel
---
## Parallel Example: Phase 2 (Foundational)
```bash
# Write tests in parallel:
Task T004: "Unit test for cancelRsvp in RsvpServiceTest.java"
Task T005: "Integration test for DELETE endpoint in EventControllerIntegrationTest.java"
# Then implement sequentially: T006 → T007 → T008 → T009 → T010 → T011 → T012
```
## Parallel Example: US1 + US2
```bash
# After Phase 2 completes, launch in parallel:
# Stream A (US1): T013 → T014 → T015 → T016 → T017
# Stream B (US2): T018 → T019 → T020 → T021
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (OpenAPI + type generation)
2. Complete Phase 2: Foundational (backend DELETE endpoint)
3. Complete Phase 3: User Story 1 (cancel from detail view)
4. **STOP and VALIDATE**: Test cancel flow end-to-end
5. Deploy/demo if ready
### Incremental Delivery
1. Setup + Foundational → Backend ready
2. Add US1 → Cancel from detail view works → Deploy (MVP!)
3. Add US2 → Auto-cancel on list removal → Deploy
4. Add US3 → Stale token edge cases handled → Deploy
5. Each story adds value without breaking previous stories
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story should be independently completable and testable
- Verify tests fail before implementing
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- Backend idempotent DELETE (204 always) simplifies all frontend error handling
- The `@Transactional` annotation is required on the service method calling JPA `deleteBy...`