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

158 lines
8.8 KiB
Markdown

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