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>
158 lines
8.8 KiB
Markdown
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).
|