Files
fete/specs/008-rsvp/research.md
nitrix 4828d06aba Add 008-rsvp feature spec and design artifacts
Spec, research decisions, implementation plan, data model,
API contract, and task breakdown for the RSVP feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:48:00 +01:00

8.8 KiB

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):

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());
    }
}
public record OrganizerToken(UUID value) { /* same pattern */ }
public record RsvpToken(UUID value) { /* same pattern */ }

Impact on existing code:

  • Event.java: UUID eventTokenEventToken eventToken, UUID organizerTokenOrganizerToken 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:

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:

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