# Research: RSVP to an Event (008) **Date**: 2026-03-06 | **Status**: Complete ## R-1: RSVP Endpoint Design **Decision**: `POST /api/events/{eventToken}/rsvps` creates an RSVP. Returns `201` with `rsvpToken` on success. Rejects with `409 Conflict` if event expired. **Rationale**: RSVPs are a sub-resource of events — nesting under the event token is RESTful and groups operations logically. The event token in the path identifies the event; no separate event ID in the request body needed. **Flow**: 1. `EventController` implements generated `EventsApi.createRsvp()` — same controller, same tag, sub-resource of events. 2. Controller resolves the event via `eventToken`, checks expiry. 3. New inbound port: `CreateRsvpUseCase` with `createRsvp(CreateRsvpCommand): Rsvp`. 4. `RsvpService` validates event exists + not expired, persists RSVP, returns domain model. 5. Controller maps to `CreateRsvpResponse` DTO (contains `rsvpToken`). 6. 404 if event not found, 409 if event expired, 400 for validation errors. **Alternatives considered**: - `POST /api/rsvps` with eventToken in body — rejected because RSVPs are always scoped to an event. Nested resource is cleaner. - Separate `RsvpController` — rejected because the URL is under `/events/`, so it belongs in `EventController`. One controller per resource root (KISS). - `PUT` instead of `POST` — rejected because the client doesn't know the rsvpToken before creation. ## R-2: Token Value Objects **Decision**: Introduce all three token types as Java records wrapping `UUID`: `EventToken`, `OrganizerToken`, and `RsvpToken`. Refactor the existing `Event` domain model and all layers (service, controller, repository, persistence adapter) to use the typed tokens instead of raw `UUID`. **Rationale**: The spec mandates typed token records. Introducing `RsvpToken` alone while leaving the other two as raw UUIDs would create an inconsistency in the domain model. All three tokens serve the same purpose (type-safe identification) and should be modeled uniformly. The cross-cutting refactoring touches existing code but is mechanical and well-covered by existing tests. **Implementation pattern** (same for all three): ```java package de.fete.domain.model; public record EventToken(UUID value) { public EventToken { Objects.requireNonNull(value, "eventToken must not be null"); } public static EventToken generate() { return new EventToken(UUID.randomUUID()); } } ``` ```java public record OrganizerToken(UUID value) { /* same pattern */ } public record RsvpToken(UUID value) { /* same pattern */ } ``` **Impact on existing code**: - `Event.java`: `UUID eventToken` → `EventToken eventToken`, `UUID organizerToken` → `OrganizerToken organizerToken` - `EventService.java`: `UUID.randomUUID()` → `EventToken.generate()` / `OrganizerToken.generate()` - `EventController.java`: unwrap tokens at API boundary (`token.value()`) - `EventRepository.java` / `EventPersistenceAdapter.java`: map between domain tokens and raw UUIDs for JPA - `EventJpaEntity.java`: stays with raw `UUID` columns (JPA mapping layer) - All existing tests: update to use typed tokens **Alternatives considered**: - Use raw UUID everywhere — rejected because the spec explicitly requires typed records and they prevent mixing up token types at compile time. - Introduce only `RsvpToken` now — rejected because it creates an inconsistency. All three should be uniform. ## R-3: Attendee Count Population **Decision**: Populate `attendeeCount` in `GetEventResponse` by counting RSVP rows for the event. The count query lives in the `RsvpRepository` port and is called by `EventService` (or a query in `RsvpService` delegated to by `EventController`). **Rationale**: The `attendeeCount` field already exists in the API contract (returns 0 today per R-3 in 007). Now it gets real data. Since an RSVP entry's existence implies attendance (no `attending` boolean), the count is simply `COUNT(*) WHERE event_id = ?`. **Implementation approach**: Add `countByEventId(Long eventId)` to `RsvpRepository`. `EventService.getByEventToken()` returns the Event domain object; the controller queries the RSVP count separately and sets it on the response. This keeps Event and RSVP domains loosely coupled. **Alternatives considered**: - Store count on the Event entity (denormalized) — rejected because it introduces update anomalies and requires synchronization logic. - Join query in EventRepository — rejected because it couples Event persistence to RSVP schema. ## R-4: Expired Event Guard **Decision**: Both client and server enforce the expiry guard. Client hides the RSVP form when `expired === true` (from GetEventResponse). Server rejects `POST /rsvps` with `409 Conflict` when `event.expiryDate < today`. **Rationale**: Defense in depth. The client check is UX (don't show a form that can't succeed). The server check is the authoritative guard (clients can be bypassed). Using `409 Conflict` rather than `400 Bad Request` because the request format is valid — it's the event state that prevents the operation. **Alternatives considered**: - Client-only guard — rejected because clients can be bypassed. - `400 Bad Request` — rejected because the request body is valid; the conflict is with the event's state. - `422 Unprocessable Entity` — acceptable but `409` better communicates "the resource state conflicts with this operation." ## R-5: localStorage Schema for RSVP **Decision**: Extend the existing `fete:events` localStorage structure. Each `StoredEvent` entry gains optional `rsvpToken` and `rsvpName` fields. **Rationale**: The existing `useEventStorage` composable already stores events by token. Adding RSVP data to the same entry avoids a second localStorage key and keeps event data co-located. The spec requires storing: rsvpToken, name, event token, event title, event date — the last three are already in `StoredEvent`. **Schema change**: ```typescript interface StoredEvent { eventToken: string organizerToken?: string // existing (for organizers) title: string // existing dateTime: string // existing expiryDate: string // existing rsvpToken?: string // NEW — present after RSVP rsvpName?: string // NEW — guest's submitted name } ``` **Alternatives considered**: - Separate `fete:rsvps` localStorage key — rejected because it duplicates event metadata (title, date) and complicates lookups. - IndexedDB — rejected (over-engineering for a few KBs of data). ## R-6: Bottom Sheet UI Pattern **Decision**: Implement the bottom sheet as a Vue component using CSS transforms and transitions. No UI library dependency. **Rationale**: The spec requires a bottom sheet for the RSVP form. A custom implementation using `transform: translateY()` with CSS transitions is lightweight, accessible, and avoids new dependencies (Principle V). The sheet slides up from the bottom on open and back down on close. **Key implementation details**: - Overlay backdrop (semi-transparent) with click-to-dismiss - `` element or ARIA `role="dialog"` with `aria-modal="true"` - Focus trap inside the sheet (keyboard accessibility) - ESC key to close - Transition: `transform 0.3s ease-out` - Mobile: full-width, max-height ~50vh. Desktop: full-width within the 480px column. **Alternatives considered**: - Modal/dialog instead of bottom sheet — rejected because bottom sheets are the mobile-native pattern for contextual actions. - Headless UI library (e.g., @headlessui/vue) — rejected because it adds a dependency for a single component. Custom implementation is ~50 lines. ## R-7: Sticky Bottom Bar **Decision**: The sticky bar is a `position: fixed` element at the bottom of the viewport, within the content column (max 480px). It contains either the RSVP CTA button or the RSVP status text. **Rationale**: The spec defines two states for the bar: 1. **No RSVP**: Shows CTA button (accent color, "Ich bin dabei!" or similar) 2. **Has RSVP**: Shows status text ("Du kommst!" + edit hint, though edit is out of scope) The bar state is determined by checking localStorage for an rsvpToken for the current event. **CSS pattern**: ```css .sticky-bar { position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); width: 100%; max-width: 480px; /* matches content column */ padding: 1rem 1.2rem; /* glass-morphism or solid surface */ } ``` **Alternatives considered**: - `position: sticky` at bottom of content — rejected because it only sticks within the scroll container, not the viewport. Fixed is needed for always-visible CTA. ## R-8: Cancelled Event Guard (Deferred) **Decision**: FR-010 (cancelled event guard) is deferred per spec. The code will NOT check for cancellation status. This will be added when US-18 (cancel event) is implemented. **Rationale**: No cancellation field exists on the Event model yet. Adding a guard for a non-existent state violates KISS (Principle IV).