# 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
- `<dialog>` 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).
