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>
This commit is contained in:
157
specs/008-rsvp/research.md
Normal file
157
specs/008-rsvp/research.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user