From 4828d06aba111a666b9120a691e9b5f1eab053cf Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 11:48:00 +0100 Subject: [PATCH 1/8] 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 --- .specify/memory/ideen.md | 3 + specs/008-rsvp/contracts/create-rsvp.yaml | 79 +++++++++ specs/008-rsvp/data-model.md | 93 +++++++++++ specs/008-rsvp/plan.md | 114 +++++++++++++ specs/008-rsvp/quickstart.md | 58 +++++++ specs/008-rsvp/research.md | 157 ++++++++++++++++++ specs/008-rsvp/spec.md | 72 ++++---- specs/008-rsvp/tasks.md | 190 ++++++++++++++++++++++ 8 files changed, 731 insertions(+), 35 deletions(-) create mode 100644 specs/008-rsvp/contracts/create-rsvp.yaml create mode 100644 specs/008-rsvp/data-model.md create mode 100644 specs/008-rsvp/plan.md create mode 100644 specs/008-rsvp/quickstart.md create mode 100644 specs/008-rsvp/research.md create mode 100644 specs/008-rsvp/tasks.md diff --git a/.specify/memory/ideen.md b/.specify/memory/ideen.md index 15217e4..e256a04 100644 --- a/.specify/memory/ideen.md +++ b/.specify/memory/ideen.md @@ -33,6 +33,7 @@ Person erstellt via App eine Veranstaltung und schickt seine Freunden irgendwie * Updaten der Veranstaltung * Einsicht angemeldete Gäste, kann bei Bedarf Einträge entfernen * Featureideen: + * Organisator kann einstellen, ob Attendee-Namensliste öffentlich auf der Event-Seite sichtbar ist (default: nur für Organisator). Wenn öffentlich, muss im RSVP-Bottom-Sheet eine Warnung angezeigt werden, dass der Name öffentlich sichtbar sein wird. * Link-Previews (OpenGraph Meta-Tags): Generische OG-Tags mit App-Branding (z.B. "fete — Du wurdest eingeladen") damit geteilte Links in WhatsApp/Signal/Telegram hübsch aussehen. Keine Event-Daten an Crawler aus Privacy-Gründen. → Eigene User Story. * Kalender-Integration: .ics-Download + optional webcal:// für Live-Updates bei Änderungen * Änderungen zum ursprünglichen Inhalt (z.b. geändertes datum/ort) werden iwi hervorgehoben @@ -40,6 +41,8 @@ Person erstellt via App eine Veranstaltung und schickt seine Freunden irgendwie * QR Code generieren (z.B. für Plakate/Flyer) * Ablaufdatum als Pflichtfeld, nach dem alle gespeicherten Daten gelöscht werden * Übersichtsliste im LocalStorage: Alle Events die man zugesagt oder gemerkt hat (vgl. spliit) + * RSVP editieren: Gast kann seine bestehende Zusage bearbeiten (Name ändern via PUT mit rsvpToken) oder zurückziehen (DELETE mit rsvpToken). Bottom Sheet öffnet sich im Edit-Mode mit pre-filled Name + "Zusage zurückziehen"-Button. Später ergänzen: "Absagen und merken" (Kombination mit 011-bookmark-event). Ausgelagert aus 008-rsvp um den Scope klein zu halten. + * Organizer-Gästeliste: Namensliste der Zusagen nur für Organisator sichtbar (über Organizer-Link). Gehört thematisch zu 009-guest-list, nicht zu 008-rsvp. * Sicherheit/Missbrauchsschutz: * Nicht-erratbare Event-Tokens (z.B. UUIDs) * Event-Erstellung ist offen, kein Login/Passwort/Invite-Code nötig diff --git a/specs/008-rsvp/contracts/create-rsvp.yaml b/specs/008-rsvp/contracts/create-rsvp.yaml new file mode 100644 index 0000000..6b6315a --- /dev/null +++ b/specs/008-rsvp/contracts/create-rsvp.yaml @@ -0,0 +1,79 @@ +# OpenAPI contract addition for POST /events/{eventToken}/rsvps +# To be merged into backend/src/main/resources/openapi/api.yaml + +paths: + /events/{eventToken}/rsvps: + post: + operationId: createRsvp + summary: Submit an RSVP for an event + tags: + - events + parameters: + - name: eventToken + in: path + required: true + schema: + type: string + format: uuid + description: Public event token + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRsvpRequest" + responses: + "201": + description: RSVP created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRsvpResponse" + "400": + description: Validation failed (e.g. blank name) + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ValidationProblemDetail" + "404": + description: Event not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "409": + description: Event has expired — RSVPs no longer accepted + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + +components: + schemas: + CreateRsvpRequest: + type: object + required: + - name + properties: + name: + type: string + minLength: 1 + maxLength: 100 + description: Guest's display name + example: "Max Mustermann" + + CreateRsvpResponse: + type: object + required: + - rsvpToken + - name + properties: + rsvpToken: + type: string + format: uuid + description: Token identifying this RSVP (store client-side for future updates) + example: "d4e5f6a7-b8c9-0123-4567-890abcdef012" + name: + type: string + description: Guest's display name as stored + example: "Max Mustermann" diff --git a/specs/008-rsvp/data-model.md b/specs/008-rsvp/data-model.md new file mode 100644 index 0000000..8bce01e --- /dev/null +++ b/specs/008-rsvp/data-model.md @@ -0,0 +1,93 @@ +# Data Model: RSVP to an Event (008) + +**Date**: 2026-03-06 + +## Entities + +### Rsvp (NEW) + +| Field | Type | Required | Constraints | Notes | +|------------|----------------|----------|--------------------------------|------------------------------------| +| id | Long | yes | BIGSERIAL, PK | Internal only, never exposed | +| rsvpToken | RsvpToken | yes | UNIQUE, NOT NULL | Server-generated UUID, returned to client | +| eventId | Long | yes | FK -> events.id, NOT NULL | Which event this RSVP belongs to | +| name | String | yes | 1-100 chars, NOT NULL | Guest's display name | + +**Notes**: +- No `attending` boolean — existence of an entry implies attendance (per spec). +- No `createdAt` — not required by the spec. Can be added later if needed (e.g. for guest list sorting in 009). +- Duplicates from different devices or cleared localStorage are accepted (privacy trade-off). + +### Token Value Objects (NEW) + +| Record | Field | Type | Notes | +|------------------|-------|------|-----------------------------------------------| +| `EventToken` | value | UUID | Immutable, non-null. Java record wrapping UUID | +| `OrganizerToken` | value | UUID | Immutable, non-null. Java record wrapping UUID | +| `RsvpToken` | value | UUID | Immutable, non-null. Java record wrapping UUID | + +**Purpose**: Type-safe wrappers preventing mix-ups between the three token types at compile time. All generated server-side via `UUID.randomUUID()`. JPA entities continue to use raw `UUID` columns — mapping happens in the persistence adapters. + +### Event (MODIFIED — token fields change type) + +The Event domain model's `eventToken` and `organizerToken` fields change from raw `UUID` to their typed record wrappers. No database schema change — the JPA entity keeps raw `UUID` columns. + +| Field | Old Type | New Type | +|-----------------|----------|------------------| +| eventToken | UUID | EventToken | +| organizerToken | UUID | OrganizerToken | + +The `attendeeCount` was already added to the API response in 007-view-event — it now gets populated from a count query instead of returning 0. + +### StoredEvent (frontend localStorage — modified) + +| Field | Type | Required | Notes | +|----------------|--------|----------|------------------------------------| +| eventToken | string | yes | Existing | +| organizerToken | string | no | Existing (organizer flow) | +| title | string | yes | Existing | +| dateTime | string | yes | Existing | +| expiryDate | string | yes | Existing | +| rsvpToken | string | no | **NEW** — set after RSVP submission | +| rsvpName | string | no | **NEW** — guest's submitted name | + +## Validation Rules + +- `name`: required, 1-100 characters, trimmed. Blank or whitespace-only is rejected. +- `rsvpToken`: server-generated, never from client input on create. +- `eventId`: must reference an existing, non-expired event. + +## Relationships + +``` +Event 1 <---- * Rsvp + | | + eventToken rsvpToken (unique) + (public) (returned to client) +``` + +## Type Mapping (full stack) + +| Concept | Java | PostgreSQL | OpenAPI | TypeScript | +|--------------|-------------------|---------------|---------------------|------------| +| RSVP ID | `Long` | `bigserial` | N/A (not exposed) | N/A | +| RSVP Token | `RsvpToken` | `uuid` | `string` `uuid` | `string` | +| Event FK | `Long` | `bigint` | N/A (path param) | N/A | +| Guest name | `String` | `varchar(100)`| `string` | `string` | +| Attendee cnt | `long` | `count(*)` | `integer` | `number` | + +## Database Migration + +New Liquibase changeset `003-create-rsvps-table.xml`: + +```sql +CREATE TABLE rsvps ( + id BIGSERIAL PRIMARY KEY, + rsvp_token UUID NOT NULL UNIQUE, + event_id BIGINT NOT NULL REFERENCES events(id), + name VARCHAR(100) NOT NULL +); + +CREATE INDEX idx_rsvps_event_id ON rsvps(event_id); +CREATE INDEX idx_rsvps_rsvp_token ON rsvps(rsvp_token); +``` diff --git a/specs/008-rsvp/plan.md b/specs/008-rsvp/plan.md new file mode 100644 index 0000000..257b62d --- /dev/null +++ b/specs/008-rsvp/plan.md @@ -0,0 +1,114 @@ +# Implementation Plan: RSVP to an Event + +**Branch**: `008-rsvp` | **Date**: 2026-03-06 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/008-rsvp/spec.md` + +## Summary + +Add RSVP functionality to the event detail page. Backend: new `POST /api/events/{eventToken}/rsvps` endpoint that persists an RSVP (guest name) and returns an `rsvpToken`. Populates the existing `attendeeCount` field with real data from a count query. Rejects RSVPs on expired events (409). Frontend: fullscreen event presentation with sticky bottom bar (RSVP CTA or status), bottom sheet with RSVP form (name + submit). localStorage stores rsvpToken and name per event. No account required. + +## Technical Context + +**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend) +**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript +**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations) +**Testing**: JUnit (backend), Vitest (frontend unit), Playwright + MSW (frontend E2E) +**Target Platform**: Self-hosted web application (Docker) +**Project Type**: Web service + SPA +**Performance Goals**: N/A (single-user scale, self-hosted) +**Constraints**: No external resources (CDNs, fonts, tracking), WCAG AA, privacy-first, no PII logging +**Scale/Scope**: New RSVP domain (model + service + controller + persistence), new frontend components (bottom sheet, sticky bar), modified event detail view + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Privacy by Design | PASS | No PII logged. Only guest-entered name stored. No IP logging. No tracking. Attendee names not exposed publicly (count only). Unprotected endpoint is a conscious privacy trade-off per spec. | +| II. Test-Driven Methodology | PASS | TDD enforced: backend unit + integration tests, frontend unit tests, E2E tests. Tests written before implementation. | +| III. API-First Development | PASS | OpenAPI spec updated first. New endpoint + schemas with `example:` fields. Types generated before implementation. | +| IV. Simplicity & Quality | PASS | Minimal scope: one POST endpoint, one domain entity, one bottom sheet component. No CAPTCHA, no rate limiting, no edit/withdraw (deferred). Cancelled event guard deferred to US-18. | +| V. Dependency Discipline | PASS | No new dependencies. Bottom sheet is CSS + Vue (~50 lines). No UI library. | +| VI. Accessibility | PASS | Bottom sheet uses dialog role + aria-modal. Focus trap. ESC to close. Keyboard navigable. WCAG AA contrast via design system. | + +**Post-Phase-1 re-check**: All gates still pass. Three token value objects (`EventToken`, `OrganizerToken`, `RsvpToken`) introduced uniformly — justified by spec requirement for type-safe tokens. Refactoring existing Event model to use typed tokens is a mechanical change well-covered by existing tests. + +## Project Structure + +### Documentation (this feature) + +```text +specs/008-rsvp/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0: research decisions (R-1 through R-8) +├── data-model.md # Phase 1: Rsvp entity, RsvpToken value object +├── quickstart.md # Phase 1: implementation overview +├── contracts/ +│ └── create-rsvp.yaml # Phase 1: POST endpoint contract +└── tasks.md # Phase 2: implementation tasks (via /speckit.tasks) +``` + +### Source Code (repository root) + +```text +backend/ +├── src/main/java/de/fete/ +│ ├── domain/ +│ │ ├── model/ +│ │ │ ├── Event.java # MODIFIED: UUID → EventToken/OrganizerToken +│ │ │ ├── EventToken.java # NEW: typed token record +│ │ │ ├── OrganizerToken.java # NEW: typed token record +│ │ │ ├── Rsvp.java # NEW: RSVP domain entity +│ │ │ └── RsvpToken.java # NEW: typed token record +│ │ └── port/ +│ │ ├── in/CreateRsvpUseCase.java # NEW: inbound port +│ │ └── out/RsvpRepository.java # NEW: outbound port +│ ├── application/service/ +│ │ ├── EventService.java # MODIFIED: use typed tokens +│ │ └── RsvpService.java # NEW: RSVP business logic +│ ├── adapter/ +│ │ ├── in/web/ +│ │ │ ├── EventController.java # MODIFIED: typed tokens + attendee count + createRsvp() +│ │ │ └── GlobalExceptionHandler.java # MODIFIED: handle EventExpiredException +│ │ └── out/persistence/ +│ │ ├── EventPersistenceAdapter.java # MODIFIED: map typed tokens +│ │ ├── RsvpJpaEntity.java # NEW: JPA entity +│ │ ├── RsvpJpaRepository.java # NEW: Spring Data interface +│ │ └── RsvpPersistenceAdapter.java # NEW: port implementation +├── src/main/resources/ +│ ├── openapi/api.yaml # MODIFIED: add RSVP endpoint + schemas +│ └── db/changelog/ +│ ├── db.changelog-master.xml # MODIFIED: include 003 +│ └── 003-create-rsvps-table.xml # NEW: rsvps table +└── src/test/java/de/fete/ + ├── application/service/ + │ ├── EventServiceTest.java # MODIFIED: use typed tokens + │ └── RsvpServiceTest.java # NEW: unit tests + └── adapter/in/web/ + └── EventControllerIntegrationTest.java # MODIFIED: typed tokens + RSVP integration tests + +frontend/ +├── src/ +│ ├── api/schema.d.ts # REGENERATED from OpenAPI +│ ├── components/ +│ │ ├── BottomSheet.vue # NEW: reusable bottom sheet +│ │ └── RsvpBar.vue # NEW: sticky bottom bar +│ ├── views/EventDetailView.vue # MODIFIED: integrate RSVP bar + sheet +│ ├── composables/useEventStorage.ts # MODIFIED: add rsvpToken/rsvpName +│ └── assets/main.css # MODIFIED: bottom sheet + bar styles +├── src/views/__tests__/EventDetailView.spec.ts # MODIFIED: RSVP integration tests +├── src/components/__tests__/ +│ ├── BottomSheet.spec.ts # NEW: unit tests +│ └── RsvpBar.spec.ts # NEW: unit tests +├── src/composables/__tests__/useEventStorage.spec.ts # MODIFIED: test new fields +└── e2e/ + └── event-rsvp.spec.ts # NEW: E2E tests +``` + +**Structure Decision**: Extends the existing web application structure (backend + frontend). Adds a new RSVP domain following the same hexagonal architecture pattern established in 006-create-event and 007-view-event. Cross-cutting refactoring introduces typed token value objects (`EventToken`, `OrganizerToken`, `RsvpToken`) across all layers. Two new frontend components (`BottomSheet`, `RsvpBar`) are the first entries in `src/components/` — justified because they're reusable UI primitives, not view-specific markup. + +## Complexity Tracking + +No constitution violations. No entries needed. diff --git a/specs/008-rsvp/quickstart.md b/specs/008-rsvp/quickstart.md new file mode 100644 index 0000000..00c9acd --- /dev/null +++ b/specs/008-rsvp/quickstart.md @@ -0,0 +1,58 @@ +# Quickstart: RSVP to an Event (008) + +## What this feature adds + +1. **Backend**: New `POST /api/events/{eventToken}/rsvps` endpoint that accepts an RSVP (guest name) and returns an `rsvpToken`. Populates the existing `attendeeCount` field in `GET /events/{token}` with real data. + +2. **Frontend**: Bottom sheet RSVP form on the event detail page. Sticky bottom bar with CTA (or status after RSVP). localStorage persistence of RSVP data. + +## Implementation order + +1. **Token value objects** — Create `EventToken`, `OrganizerToken`, `RsvpToken` records. Refactor `Event` domain model and all layers (service, controller, repository, persistence adapter, tests) to use typed tokens instead of raw UUID. +2. **OpenAPI spec** — Add `CreateRsvpRequest`, `CreateRsvpResponse`, and the `POST /events/{eventToken}/rsvps` endpoint. +3. **Liquibase migration** — Create `rsvps` table (003-create-rsvps-table.xml). +4. **Domain model** — `Rsvp` entity using `RsvpToken`. +5. **Ports** — `CreateRsvpUseCase` (in), `RsvpRepository` (out). +6. **Persistence adapter** — `RsvpJpaEntity`, `RsvpJpaRepository`, `RsvpPersistenceAdapter`. +7. **Service** — `RsvpService` implementing `CreateRsvpUseCase`. +8. **Controller** — Add `createRsvp()` to `EventController`. +9. **Attendee count** — Wire `RsvpRepository.countByEventId()` into the GET event flow. +10. **Frontend composable** — Extend `useEventStorage` with `rsvpToken`/`rsvpName`. +11. **Frontend UI** — Bottom sheet component, sticky bar, RSVP form. +12. **E2E tests** — RSVP submission, expired event guard, localStorage verification. + +## Key files to touch + +### Backend (new) +- `domain/model/EventToken.java` +- `domain/model/OrganizerToken.java` +- `domain/model/Rsvp.java` +- `domain/model/RsvpToken.java` +- `domain/port/in/CreateRsvpUseCase.java` +- `domain/port/out/RsvpRepository.java` +- `application/service/RsvpService.java` +- `adapter/out/persistence/RsvpJpaEntity.java` +- `adapter/out/persistence/RsvpJpaRepository.java` +- `adapter/out/persistence/RsvpPersistenceAdapter.java` +- `db/changelog/003-create-rsvps-table.xml` + +### Backend (modified) +- `domain/model/Event.java` — UUID → EventToken/OrganizerToken +- `application/service/EventService.java` — use typed tokens +- `adapter/in/web/EventController.java` — typed tokens + wire attendee count + createRsvp() +- `adapter/in/web/GlobalExceptionHandler.java` — handle `EventExpiredException` (409) +- `adapter/out/persistence/EventPersistenceAdapter.java` — map typed tokens +- `domain/port/out/EventRepository.java` — typed token in signature +- `openapi/api.yaml` — new endpoint + schemas +- `db/changelog/db.changelog-master.xml` — include new migration +- All existing tests — update to use typed tokens + +### Frontend (new) +- `src/components/BottomSheet.vue` — reusable bottom sheet +- `src/components/RsvpBar.vue` — sticky bottom bar (CTA or status) +- `e2e/event-rsvp.spec.ts` — E2E tests + +### Frontend (modified) +- `src/views/EventDetailView.vue` — integrate RSVP bar + bottom sheet +- `src/composables/useEventStorage.ts` — add rsvpToken/rsvpName fields +- `src/api/schema.d.ts` — regenerated from OpenAPI diff --git a/specs/008-rsvp/research.md b/specs/008-rsvp/research.md new file mode 100644 index 0000000..76ef5a5 --- /dev/null +++ b/specs/008-rsvp/research.md @@ -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 +- `` 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). diff --git a/specs/008-rsvp/spec.md b/specs/008-rsvp/spec.md index 77f1f55..af25fb6 100644 --- a/specs/008-rsvp/spec.md +++ b/specs/008-rsvp/spec.md @@ -5,11 +5,25 @@ **Status**: Draft **Source**: Migrated from spec/userstories.md +## Clarifications + +### Session 2026-03-06 + +- Q: How should the server deduplicate RSVPs from the same device without accounts? → A: Server generates an `rsvpToken` (UUID) on first RSVP, returns it to the client. Client stores it in localStorage per event. On re-RSVP, client sends the `rsvpToken` to update instead of create. No global device identifier — the token is event-scoped. If localStorage is lost (cleared or different device), a duplicate entry is accepted as a privacy trade-off. +- Q: Should typed token value objects be used in the backend? → A: Yes. Backend: The three token types (EventToken, OrganizerToken, RsvpToken) MUST be modeled as distinct Java record types wrapping UUID, not passed as raw UUID values. Frontend: No branded types — plain string variables with clear naming (eventToken, rsvpToken) are sufficient given TypeScript's structural typing and OpenAPI codegen. +- Q: How should the RSVP interaction be presented on the event page? → A: Fullscreen event presentation (gradient background, later Unsplash). Title prominent at top, key facts below (description, date, attendee count) — spacious layout. Sticky bottom bar with RSVP CTA. Tap opens a bottom sheet with the RSVP form. After RSVP, the bar shows status ("Du kommst!" + edit option) instead of the CTA. +- Q: How does the RSVP form handle declining? → A: There is no explicit "not attending" button. The bottom sheet only offers the attending flow (name + submit). To not attend, the guest simply closes the sheet. Withdrawing an existing RSVP (DELETE with rsvpToken) is out of scope — deferred to a separate edit-RSVP spec. +- Q: Should the attendee name list be publicly visible on the event page? → A: No. Only the attendee count is shown publicly. The full name list is visible only to the organizer (via organizer link). This maximizes guest privacy. +- Q: Should the RSVP endpoint have spam/abuse protection? → A: No. The RSVP endpoint is intentionally unprotected — risk is consciously accepted as a privacy trade-off consistent with the no-account, no-tracking philosophy. Protection measures can be retrofitted in a separate spec if real-world abuse occurs. KISS. +- Q: How is the attendee count delivered and updated? → A: As a new `attendeeCount` field in the existing Event response (no separate endpoint). Loaded once on page load, no polling or WebSocket. After the guest's own RSVP submission, the count is optimistically incremented (+1) client-side. KISS. +- Q: What determines the RSVP cutoff? → A: The event date itself. No separate expiry field. After the event date has passed, RSVPs are blocked (form hidden, server rejects). +- Q: Should the RSVP entity have an `attending` boolean field? → A: No. The server only stores attending RSVPs — existence of an entry implies attendance. No `attending` boolean needed. Deletion of entries (withdrawal) is deferred to the edit-RSVP spec. + ## User Scenarios & Testing ### User Story 1 - Submit an RSVP (Priority: P1) -A guest opens an active event page and indicates whether they will attend. If attending, they must provide their name. If not attending, the name is optional. The RSVP is sent to the server and persisted. The guest's choice, name, event token, title, and date are saved in localStorage. +A guest opens an active event page, which presents the event fullscreen (gradient background, title prominent at top, key facts below including attendee count). A sticky bottom bar shows an RSVP call-to-action. Tapping opens a bottom sheet with the RSVP form: name field + submit. The RSVP is sent to the server and persisted. The server returns an rsvpToken which, along with the name, event token, title, and date, is saved in localStorage. After submission, the bottom sheet closes and the sticky bar shows the guest's RSVP status. To not attend, the guest simply closes the sheet — no server request. **Why this priority**: Core interactive feature of the app. Without it, guests cannot communicate attendance, and the attendee list (US-2) has no data. @@ -17,30 +31,15 @@ A guest opens an active event page and indicates whether they will attend. If at **Acceptance Scenarios**: -1. **Given** a guest is on an active event page, **When** they select "I'm attending" and enter their name, **Then** the RSVP is submitted to the server, persisted, and the attendee list reflects the new entry. -2. **Given** a guest is on an active event page, **When** they select "I'm attending" but leave the name blank, **Then** the form is not submitted and a validation message indicating the name is required is shown. -3. **Given** a guest is on an active event page, **When** they select "I'm not attending" without entering a name, **Then** the RSVP is submitted successfully (name is optional for non-attendees). -4. **Given** a guest submits an RSVP (attending or not), **When** the submission succeeds, **Then** the guest's RSVP choice, name, event token, event title, and event date are stored in localStorage on this device. -5. **Given** a guest submits an RSVP, **When** the submission succeeds, **Then** no account, login, or personal data beyond the optionally entered name is required. +1. **Given** a guest is on an active event page, **When** they tap the RSVP CTA, enter their name, and submit, **Then** the RSVP is submitted to the server, persisted, and the attendee count updates. +2. **Given** a guest has opened the bottom sheet, **When** they leave the name blank and try to submit, **Then** the form is not submitted and a validation message is shown. +3. **Given** a guest has opened the bottom sheet, **When** they close it without submitting, **Then** no server request is made and no state changes. +4. **Given** a guest submits an RSVP, **When** the submission succeeds, **Then** the rsvpToken, name, event token, event title, and event date are stored in localStorage on this device. +5. **Given** a guest submits an RSVP, **When** the submission succeeds, **Then** no account, login, or personal data beyond the entered name is required. --- -### User Story 2 - Re-RSVP from the Same Device (Priority: P2) - -A returning guest on the same device opens an event page where they previously submitted an RSVP. The form pre-fills with their prior choice and name. Re-submitting updates the existing RSVP rather than creating a duplicate. - -**Why this priority**: Prevents duplicate entries and provides a better UX for guests who want to change their mind. Depends on Story 1 populating localStorage. - -**Independent Test**: Can be tested by RSVPing once, then reloading the event page and verifying the form is pre-filled and a second submission updates rather than duplicates the server-side record. - -**Acceptance Scenarios**: - -1. **Given** a guest has previously submitted an RSVP on this device, **When** they open the same event page again, **Then** the RSVP form is pre-filled with their previous choice and name. -2. **Given** a guest has a prior RSVP pre-filled, **When** they change their selection and re-submit, **Then** the existing server-side RSVP entry is updated and no duplicate entry is created. - ---- - -### User Story 3 - RSVP Blocked on Expired or Cancelled Events (Priority: P2) +### User Story 2 - RSVP Blocked on Expired or Cancelled Events (Priority: P2) A guest attempts to RSVP to an event that has already expired or has been cancelled. The RSVP form is not shown and the server rejects any submission attempts. @@ -58,37 +57,40 @@ A guest attempts to RSVP to an event that has already expired or has been cancel ### Edge Cases -- What happens when a guest RSVPs on two different devices? Each device stores its own localStorage entry; the server holds both RSVPs as separate entries (no deduplication across devices — acceptable per design, consistent with the no-account model). +- What happens when a guest RSVPs on two different devices? Each device stores its own localStorage entry; the server holds both RSVPs as separate entries (no deduplication across devices — accepted privacy trade-off). - What happens when the server is unreachable during RSVP submission? The submission fails; localStorage is not updated (no optimistic write). The guest sees an error and can retry. -- What happens if localStorage is cleared after RSVPing? The form no longer pre-fills and the guest can re-submit; the server will create a new RSVP entry rather than update the old one. +- What happens if localStorage is cleared after RSVPing? The sticky bar shows the CTA again (as if no prior RSVP). A new submission creates a duplicate server-side entry — accepted privacy trade-off. +- What about spam/abuse on the unprotected RSVP endpoint? Risk is consciously accepted (KISS, privacy-first). No rate limiting, no honeypot, no CAPTCHA. Can be retrofitted in a future spec if real-world abuse occurs. ## Requirements ### Functional Requirements -- **FR-001**: The RSVP form MUST offer exactly two choices: "I'm attending" and "I'm not attending". -- **FR-002**: When the guest selects "I'm attending", the name field MUST be required; submission MUST be blocked if the name is blank. -- **FR-003**: When the guest selects "I'm not attending", the name field MUST be optional; submission MUST succeed without a name. -- **FR-004**: On successful RSVP submission, the server MUST persist the RSVP associated with the event. -- **FR-005**: On successful RSVP submission, the client MUST store the guest's RSVP choice and name in localStorage, keyed by event token. +- **FR-001**: The RSVP bottom sheet MUST offer an attending flow only: name field (required, max 100 characters) + submit. There is no explicit "not attending" option — the guest simply closes the sheet. +- **FR-002**: Submission MUST be blocked if the name is blank or exceeds 100 characters. +- **FR-003**: If a prior RSVP for this event exists in localStorage (rsvpToken present), the sticky bottom bar MUST show the guest's RSVP status instead of the initial CTA. Editing the RSVP (name change, withdrawal) is out of scope for this spec. +- **FR-004**: On successful attending RSVP submission, the server MUST persist the RSVP associated with the event and return an rsvpToken. +- **FR-005**: On successful RSVP submission, the client MUST store the guest's name and the server-returned rsvpToken in localStorage, keyed by event token. - **FR-006**: On successful RSVP submission, the client MUST store the event token, event title, and event date in localStorage (to support the local event overview, US-7). -- **FR-007**: If a prior RSVP for this event exists in localStorage, the form MUST pre-fill with the stored choice and name on page load. -- **FR-008**: Re-submitting an RSVP from a device that has an existing server-side entry for this event MUST update the existing entry, not create a new one. +- **FR-013**: The event page MUST present the event fullscreen with a sticky bottom bar containing the RSVP call-to-action. Tapping the CTA MUST open a bottom sheet with the RSVP form. +- **FR-014**: After successful RSVP submission, the bottom sheet MUST close and the sticky bar MUST transition to showing the RSVP status. - **FR-009**: The RSVP form MUST NOT be shown and the server MUST reject RSVP submissions after the event's expiry date has passed. - **FR-010**: The RSVP form MUST NOT be shown and the server MUST reject RSVP submissions if the event has been cancelled [enforcement deferred until US-18 is implemented]. -- **FR-011**: RSVP submission MUST NOT require an account, login, or any personal data beyond the optionally entered name. +- **FR-011**: RSVP submission MUST NOT require an account, login, or any personal data beyond the entered name. - **FR-012**: No personal data or IP address MUST be logged on the server when processing an RSVP. +- **FR-015**: The event page MUST show only the attendee count publicly. The full attendee name list is out of scope (see 009-guest-list). ### Key Entities -- **RSVP**: Represents a guest's attendance declaration. Attributes: event token reference, attending status (boolean), optional name, creation/update timestamp. The server-side identity key for deduplication is the combination of event token and a device-bound identifier [NEEDS EXPANSION: deduplication mechanism to be defined during implementation]. +- **RSVP**: Represents a guest's attendance declaration. Attributes: rsvpToken (server-generated UUID, returned to client), event reference, name (required), creation timestamp. Existence of an entry implies attendance — no `attending` boolean. The rsvpToken is returned to the client for future use (editing/withdrawal in a later spec). Duplicates from lost localStorage or different devices are accepted as a privacy trade-off. +- **RsvpToken**: A server-generated, event-scoped UUID identifying a single RSVP entry. Modeled as a distinct Java record type (alongside EventToken and OrganizerToken). Stored client-side in localStorage per event. ## Success Criteria ### Measurable Outcomes -- **SC-001**: A guest can submit an RSVP (attending with name, or not attending without name) from the event page without an account. -- **SC-002**: Submitting an RSVP from the same device twice results in exactly one server-side RSVP entry for that guest (no duplicates). +- **SC-001**: A guest can submit an RSVP (name + submit) from the event page without an account. +- **SC-002**: After submitting, the sticky bar shows the guest's RSVP status (not the CTA). - **SC-003**: After submitting an RSVP, the local event overview (US-7) can display the event without a server request (event token, title, and date are in localStorage). - **SC-004**: The RSVP form is not shown on expired events, and direct server submissions for expired events are rejected. - **SC-005**: No name, IP address, or personal data beyond the submitted name is stored or logged by the server in connection with an RSVP. diff --git a/specs/008-rsvp/tasks.md b/specs/008-rsvp/tasks.md new file mode 100644 index 0000000..88766cd --- /dev/null +++ b/specs/008-rsvp/tasks.md @@ -0,0 +1,190 @@ +# Tasks: RSVP to an Event + +**Input**: Design documents from `/specs/008-rsvp/` +**Prerequisites**: plan.md, spec.md, data-model.md, contracts/create-rsvp.yaml, research.md, quickstart.md + +**Tests**: Included — constitution mandates Test-Driven Methodology (tests before implementation). + +**Organization**: Tasks grouped by user story for independent implementation and testing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2) +- Exact file paths included in descriptions + +## Phase 1: Setup + +**Purpose**: OpenAPI spec and database migration — shared infrastructure for all user stories + +- [x] T001 Update OpenAPI spec with RSVP endpoint, request/response schemas, and `attendeeCount` population in `backend/src/main/resources/openapi/api.yaml` +- [x] T002 [P] Create Liquibase migration for rsvps table in `backend/src/main/resources/db/changelog/003-create-rsvps-table.xml` +- [x] T003 [P] Include new migration in `backend/src/main/resources/db/changelog/db.changelog-master.xml` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Token value objects and cross-cutting refactoring — MUST complete before user stories + +**Why blocking**: All new RSVP code uses typed tokens. Existing code must be refactored first to avoid mixing raw UUID and typed tokens. + +- [x] T004 [P] Create `EventToken` record in `backend/src/main/java/de/fete/domain/model/EventToken.java` +- [x] T005 [P] Create `OrganizerToken` record in `backend/src/main/java/de/fete/domain/model/OrganizerToken.java` +- [x] T006 [P] Create `RsvpToken` record in `backend/src/main/java/de/fete/domain/model/RsvpToken.java` +- [x] T007 Refactor `Event` domain model to use `EventToken`/`OrganizerToken` in `backend/src/main/java/de/fete/domain/model/Event.java` +- [x] T008 Refactor `EventRepository` port to use typed tokens in `backend/src/main/java/de/fete/domain/port/out/EventRepository.java` +- [x] T009 Refactor `EventPersistenceAdapter` to map typed tokens in `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java` +- [x] T010 Refactor `EventService` to use typed tokens in `backend/src/main/java/de/fete/application/service/EventService.java` +- [x] T011 Refactor `EventController` to unwrap/wrap typed tokens at API boundary in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` +- [x] T012 Update `EventServiceTest` to use typed tokens in `backend/src/test/java/de/fete/application/service/EventServiceTest.java` +- [x] T013 Update `EventControllerIntegrationTest` to use typed tokens in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java` +- [x] T014 Verify all existing tests pass after token refactoring (`cd backend && ./mvnw test`) + +**Checkpoint**: All existing tests green with typed tokens. New RSVP domain work can begin. + +--- + +## Phase 3: User Story 1 — Submit an RSVP (Priority: P1) MVP + +**Goal**: A guest can open an active event page, tap the RSVP CTA, enter their name, submit, and see confirmation. Server persists the RSVP and returns an rsvpToken. localStorage stores RSVP data. Attendee count is populated from real data. + +**Independent Test**: Open an event page, submit an RSVP with a name, verify attendee count updates, verify localStorage contains rsvpToken and name. + +### Backend Tests for US1 + +- [x] T015 [P] [US1] Write unit tests for `RsvpService` (create RSVP, validation, event-not-found) in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java` +- [x] T016 [P] [US1] Write integration tests for `POST /events/{eventToken}/rsvps` (201 success, 400 validation, 404 not found) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java` + +### Backend Implementation for US1 + +- [x] T017 [P] [US1] Create `Rsvp` domain entity in `backend/src/main/java/de/fete/domain/model/Rsvp.java` +- [x] T018 [P] [US1] Create `CreateRsvpUseCase` inbound port in `backend/src/main/java/de/fete/domain/port/in/CreateRsvpUseCase.java` +- [x] T019 [P] [US1] Create `RsvpRepository` outbound port with `save()` and `countByEventId()` in `backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java` +- [x] T020 [P] [US1] Create `RsvpJpaEntity` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaEntity.java` +- [x] T021 [P] [US1] Create `RsvpJpaRepository` (Spring Data) in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java` +- [x] T022 [US1] Implement `RsvpPersistenceAdapter` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java` +- [x] T023 [US1] Implement `RsvpService` (create RSVP logic, validate event exists) in `backend/src/main/java/de/fete/application/service/RsvpService.java` +- [x] T024 [US1] Add `createRsvp()` method to `EventController` in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` +- [x] T025 [US1] Wire attendee count: add `countByEventId()` call to GET event flow, populate `attendeeCount` in response in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` +- [x] T026 [US1] Verify backend tests pass (`cd backend && ./mvnw test`) + +### Frontend Tests for US1 + +- [ ] T027 [P] [US1] Write unit tests for `BottomSheet` component in `frontend/src/components/__tests__/BottomSheet.spec.ts` +- [ ] T028 [P] [US1] Write unit tests for `RsvpBar` component in `frontend/src/components/__tests__/RsvpBar.spec.ts` +- [ ] T029 [P] [US1] Update unit tests for `useEventStorage` composable (rsvpToken/rsvpName fields) in `frontend/src/composables/__tests__/useEventStorage.spec.ts` + +### Frontend Implementation for US1 + +- [ ] T030 [US1] Regenerate TypeScript types from updated OpenAPI spec (`frontend/src/api/schema.d.ts`) +- [ ] T031 [P] [US1] Extend `useEventStorage` composable with `rsvpToken` and `rsvpName` fields in `frontend/src/composables/useEventStorage.ts` +- [ ] T032 [P] [US1] Create `BottomSheet.vue` component (slide-up, backdrop, focus trap, ESC close, aria-modal) in `frontend/src/components/BottomSheet.vue` +- [ ] T033 [P] [US1] Create `RsvpBar.vue` sticky bottom bar (CTA state + status state) in `frontend/src/components/RsvpBar.vue` +- [ ] T034 [US1] Integrate `RsvpBar` + `BottomSheet` + RSVP form into `EventDetailView`, including error state when server is unreachable, in `frontend/src/views/EventDetailView.vue` +- [ ] T035 [US1] Add bottom sheet and sticky bar styles to `frontend/src/assets/main.css` +- [ ] T036 [US1] Update `EventDetailView` unit tests for RSVP integration in `frontend/src/views/__tests__/EventDetailView.spec.ts` + +### E2E Tests for US1 + +- [ ] T037 [US1] Write E2E tests: RSVP submission flow, localStorage verification, attendee count update in `frontend/e2e/event-rsvp.spec.ts` + +**Checkpoint**: US1 complete — guest can submit RSVP, see confirmation, attendee count populated. All backend + frontend tests green. + +--- + +## Phase 4: User Story 2 — RSVP Blocked on Expired Events (Priority: P2) + +**Goal**: Expired events hide the RSVP form and the server rejects RSVP submissions with 409 Conflict. + +**Independent Test**: Attempt to RSVP to an expired event — verify form is hidden client-side and server returns 409. + +### Tests for US2 + +- [ ] T038 [P] [US2] Write backend test for expired event rejection (409) in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java` +- [ ] T039 [P] [US2] Write integration test for `POST /events/{eventToken}/rsvps` returning 409 on expired event in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java` + +### Implementation for US2 + +- [ ] T040 [US2] Add expiry check to `RsvpService.createRsvp()` — throw `EventExpiredException` when event date has passed in `backend/src/main/java/de/fete/application/service/RsvpService.java` +- [ ] T041 [US2] Handle `EventExpiredException` in `GlobalExceptionHandler` — return 409 Conflict in `backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java` +- [ ] T042 [US2] Hide RSVP bar/form on expired events in `EventDetailView` (check `expired` field from API response) in `frontend/src/views/EventDetailView.vue` +- [ ] T043 [US2] Write E2E test for expired event: verify RSVP form hidden, direct API call returns 409 in `frontend/e2e/event-rsvp.spec.ts` +- [ ] T044 [US2] Verify all tests pass (`cd backend && ./mvnw test && cd ../frontend && npm run test:unit`) + +**Checkpoint**: US2 complete — expired events block RSVPs client-side and server-side. + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: Final verification across all stories + +- [ ] T045 Run full backend verify (`cd backend && ./mvnw verify`) +- [ ] T046 Run frontend build and type-check (`cd frontend && npm run build`) +- [ ] T047 Run all E2E tests (`cd frontend && npx playwright test`) +- [ ] T048 Visual verification of RSVP flow using `browser-interactive-testing` skill + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on T001 (OpenAPI spec) for schema awareness; T002-T003 (migration) independent +- **US1 (Phase 3)**: Depends on Phase 2 completion (typed tokens in place) +- **US2 (Phase 4)**: Depends on Phase 3 (RSVP creation must exist before expiry guard) +- **Polish (Phase 5)**: Depends on all user stories complete + +### User Story Dependencies + +- **US1 (P1)**: Can start after Phase 2 — no dependency on US2 +- **US2 (P2)**: Depends on US1 (the RSVP endpoint and form must exist before adding the expiry guard) + +### Within Each User Story + +- Tests MUST be written first and FAIL before implementation +- Domain model/ports before persistence adapters +- Persistence before services +- Services before controllers +- Backend before frontend (API must exist for frontend to consume) +- Frontend components before view integration +- Unit tests before E2E tests + +### Parallel Opportunities + +**Phase 1**: T002 and T003 can run in parallel with T001 +**Phase 2**: T004, T005, T006 in parallel; then T007-T013 sequentially (refactoring chain) +**Phase 3 Backend**: T015+T016 (tests) in parallel; T017+T018+T019+T020+T021 (domain/ports/JPA) in parallel; then T022→T023→T024→T025 sequential +**Phase 3 Frontend**: T027+T028+T029 (tests) in parallel; T031+T032+T033 in parallel; then T034→T035→T036→T037 sequential +**Phase 4**: T038+T039 (tests) in parallel; then T040→T041→T042→T043→T044 sequential + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (OpenAPI + migration) +2. Complete Phase 2: Foundational (token value objects + refactoring) +3. Complete Phase 3: User Story 1 (full RSVP flow) +4. **STOP and VALIDATE**: Guest can submit RSVP, see confirmation, attendee count works +5. Deploy/demo if ready + +### Incremental Delivery + +1. Setup + Foundational → Token refactoring complete, schema ready +2. Add US1 → Full RSVP flow works → Deploy/Demo (MVP!) +3. Add US2 → Expired events guarded → Deploy/Demo +4. Polish → All tests green, visual verification done + +--- + +## Notes + +- Cancelled event guard (FR-010) is deferred until US-18 — NOT included in tasks +- No CAPTCHA/rate-limiting per spec (KISS, privacy-first) +- RSVP editing/withdrawal deferred to separate edit-RSVP spec +- Frontend uses plain `string` for tokens (no branded types) per clarification +- Backend uses typed records (`EventToken`, `OrganizerToken`, `RsvpToken`) per clarification From a625e34fe4a87775d7c9e739a4b6d673920fbfdc Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 11:49:49 +0100 Subject: [PATCH 2/8] Add RSVP creation endpoint with typed tokens and attendee count Introduce typed token value objects (EventToken, OrganizerToken, RsvpToken) and refactor all existing Event code to use them. Add POST /events/{token}/rsvps endpoint that persists an RSVP and returns an rsvpToken. Populate attendeeCount in GET /events/{token} from a real count query instead of hardcoded 0. Includes: OpenAPI spec, Liquibase migration (rsvps table with ON DELETE CASCADE), domain model, hexagonal ports/adapters, service layer, and full test coverage (unit + integration). Co-Authored-By: Claude Opus 4.6 --- .../fete/adapter/in/web/EventController.java | 39 +++++- .../persistence/EventPersistenceAdapter.java | 15 +-- .../out/persistence/RsvpJpaEntity.java | 68 +++++++++++ .../out/persistence/RsvpJpaRepository.java | 14 +++ .../persistence/RsvpPersistenceAdapter.java | 48 ++++++++ .../service/EventExpiredException.java | 12 ++ .../application/service/EventService.java | 9 +- .../fete/application/service/RsvpService.java | 43 +++++++ .../main/java/de/fete/domain/model/Event.java | 17 ++- .../java/de/fete/domain/model/EventToken.java | 18 +++ .../de/fete/domain/model/OrganizerToken.java | 18 +++ .../main/java/de/fete/domain/model/Rsvp.java | 50 ++++++++ .../java/de/fete/domain/model/RsvpToken.java | 18 +++ .../domain/port/in/CreateRsvpUseCase.java | 11 ++ .../fete/domain/port/in/GetEventUseCase.java | 4 +- .../fete/domain/port/out/EventRepository.java | 4 +- .../fete/domain/port/out/RsvpRepository.java | 13 ++ .../db/changelog/003-create-rsvps-table.xml | 36 ++++++ .../db/changelog/db.changelog-master.xml | 1 + backend/src/main/resources/openapi/api.yaml | 74 +++++++++++ .../web/EventControllerIntegrationTest.java | 81 ++++++++++++ .../EventPersistenceAdapterTest.java | 13 +- .../application/service/EventServiceTest.java | 6 +- .../application/service/RsvpServiceTest.java | 115 ++++++++++++++++++ 24 files changed, 688 insertions(+), 39 deletions(-) create mode 100644 backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaEntity.java create mode 100644 backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java create mode 100644 backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java create mode 100644 backend/src/main/java/de/fete/application/service/EventExpiredException.java create mode 100644 backend/src/main/java/de/fete/application/service/RsvpService.java create mode 100644 backend/src/main/java/de/fete/domain/model/EventToken.java create mode 100644 backend/src/main/java/de/fete/domain/model/OrganizerToken.java create mode 100644 backend/src/main/java/de/fete/domain/model/Rsvp.java create mode 100644 backend/src/main/java/de/fete/domain/model/RsvpToken.java create mode 100644 backend/src/main/java/de/fete/domain/port/in/CreateRsvpUseCase.java create mode 100644 backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java create mode 100644 backend/src/main/resources/db/changelog/003-create-rsvps-table.xml create mode 100644 backend/src/test/java/de/fete/application/service/RsvpServiceTest.java diff --git a/backend/src/main/java/de/fete/adapter/in/web/EventController.java b/backend/src/main/java/de/fete/adapter/in/web/EventController.java index a0fc363..e103ab8 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/EventController.java +++ b/backend/src/main/java/de/fete/adapter/in/web/EventController.java @@ -3,13 +3,19 @@ package de.fete.adapter.in.web; import de.fete.adapter.in.web.api.EventsApi; import de.fete.adapter.in.web.model.CreateEventRequest; import de.fete.adapter.in.web.model.CreateEventResponse; +import de.fete.adapter.in.web.model.CreateRsvpRequest; +import de.fete.adapter.in.web.model.CreateRsvpResponse; import de.fete.adapter.in.web.model.GetEventResponse; import de.fete.application.service.EventNotFoundException; import de.fete.application.service.InvalidTimezoneException; import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; +import de.fete.domain.model.Rsvp; import de.fete.domain.port.in.CreateEventUseCase; +import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.GetEventUseCase; +import de.fete.domain.port.out.RsvpRepository; import java.time.Clock; import java.time.DateTimeException; import java.time.LocalDate; @@ -25,15 +31,21 @@ public class EventController implements EventsApi { private final CreateEventUseCase createEventUseCase; private final GetEventUseCase getEventUseCase; + private final CreateRsvpUseCase createRsvpUseCase; + private final RsvpRepository rsvpRepository; private final Clock clock; - /** Creates a new controller with the given use cases and clock. */ + /** Creates a new controller with the given use cases, repository, and clock. */ public EventController( CreateEventUseCase createEventUseCase, GetEventUseCase getEventUseCase, + CreateRsvpUseCase createRsvpUseCase, + RsvpRepository rsvpRepository, Clock clock) { this.createEventUseCase = createEventUseCase; this.getEventUseCase = getEventUseCase; + this.createRsvpUseCase = createRsvpUseCase; + this.rsvpRepository = rsvpRepository; this.clock = clock; } @@ -54,8 +66,8 @@ public class EventController implements EventsApi { Event event = createEventUseCase.createEvent(command); var response = new CreateEventResponse(); - response.setEventToken(event.getEventToken()); - response.setOrganizerToken(event.getOrganizerToken()); + response.setEventToken(event.getEventToken().value()); + response.setOrganizerToken(event.getOrganizerToken().value()); response.setTitle(event.getTitle()); response.setDateTime(event.getDateTime()); response.setTimezone(event.getTimezone().getId()); @@ -66,23 +78,38 @@ public class EventController implements EventsApi { @Override public ResponseEntity getEvent(UUID token) { - Event event = getEventUseCase.getByEventToken(token) + var eventToken = new de.fete.domain.model.EventToken(token); + Event event = getEventUseCase.getByEventToken(eventToken) .orElseThrow(() -> new EventNotFoundException(token)); var response = new GetEventResponse(); - response.setEventToken(event.getEventToken()); + response.setEventToken(event.getEventToken().value()); response.setTitle(event.getTitle()); response.setDescription(event.getDescription()); response.setDateTime(event.getDateTime()); response.setTimezone(event.getTimezone().getId()); response.setLocation(event.getLocation()); - response.setAttendeeCount(0); + response.setAttendeeCount( + (int) rsvpRepository.countByEventId(event.getId())); response.setExpired( event.getExpiryDate().isBefore(LocalDate.now(clock))); return ResponseEntity.ok(response); } + @Override + public ResponseEntity createRsvp( + UUID token, CreateRsvpRequest createRsvpRequest) { + var eventToken = new EventToken(token); + Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName()); + + var response = new CreateRsvpResponse(); + response.setRsvpToken(rsvp.getRsvpToken().value()); + response.setName(rsvp.getName()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + private static ZoneId parseTimezone(String timezone) { try { return ZoneId.of(timezone); diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java b/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java index c4e78c2..e9fc2fe 100644 --- a/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java @@ -1,10 +1,11 @@ package de.fete.adapter.out.persistence; import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; +import de.fete.domain.model.OrganizerToken; import de.fete.domain.port.out.EventRepository; import java.time.ZoneId; import java.util.Optional; -import java.util.UUID; import org.springframework.stereotype.Repository; /** Persistence adapter implementing the EventRepository outbound port. */ @@ -26,15 +27,15 @@ public class EventPersistenceAdapter implements EventRepository { } @Override - public Optional findByEventToken(UUID eventToken) { - return jpaRepository.findByEventToken(eventToken).map(this::toDomain); + public Optional findByEventToken(EventToken eventToken) { + return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain); } private EventJpaEntity toEntity(Event event) { var entity = new EventJpaEntity(); entity.setId(event.getId()); - entity.setEventToken(event.getEventToken()); - entity.setOrganizerToken(event.getOrganizerToken()); + entity.setEventToken(event.getEventToken().value()); + entity.setOrganizerToken(event.getOrganizerToken().value()); entity.setTitle(event.getTitle()); entity.setDescription(event.getDescription()); entity.setDateTime(event.getDateTime()); @@ -48,8 +49,8 @@ public class EventPersistenceAdapter implements EventRepository { private Event toDomain(EventJpaEntity entity) { var event = new Event(); event.setId(entity.getId()); - event.setEventToken(entity.getEventToken()); - event.setOrganizerToken(entity.getOrganizerToken()); + event.setEventToken(new EventToken(entity.getEventToken())); + event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken())); event.setTitle(entity.getTitle()); event.setDescription(entity.getDescription()); event.setDateTime(entity.getDateTime()); diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaEntity.java b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaEntity.java new file mode 100644 index 0000000..06e783b --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaEntity.java @@ -0,0 +1,68 @@ +package de.fete.adapter.out.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; + +/** JPA entity mapping to the rsvps table. */ +@Entity +@Table(name = "rsvps") +public class RsvpJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "rsvp_token", nullable = false, unique = true) + private UUID rsvpToken; + + @Column(name = "event_id", nullable = false) + private Long eventId; + + @Column(nullable = false, length = 100) + private String name; + + /** Returns the internal database ID. */ + public Long getId() { + return id; + } + + /** Sets the internal database ID. */ + public void setId(Long id) { + this.id = id; + } + + /** Returns the RSVP token. */ + public UUID getRsvpToken() { + return rsvpToken; + } + + /** Sets the RSVP token. */ + public void setRsvpToken(UUID rsvpToken) { + this.rsvpToken = rsvpToken; + } + + /** Returns the event ID. */ + public Long getEventId() { + return eventId; + } + + /** Sets the event ID. */ + public void setEventId(Long eventId) { + this.eventId = eventId; + } + + /** Returns the guest's display name. */ + public String getName() { + return name; + } + + /** Sets the guest's display name. */ + public void setName(String name) { + this.name = name; + } +} diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java new file mode 100644 index 0000000..5f440c9 --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java @@ -0,0 +1,14 @@ +package de.fete.adapter.out.persistence; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Spring Data JPA repository for RSVP entities. */ +public interface RsvpJpaRepository extends JpaRepository { + + /** Finds an RSVP by its token. */ + java.util.Optional findByRsvpToken(UUID rsvpToken); + + /** Counts RSVPs for the given event. */ + long countByEventId(Long eventId); +} diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java new file mode 100644 index 0000000..e94181d --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java @@ -0,0 +1,48 @@ +package de.fete.adapter.out.persistence; + +import de.fete.domain.model.Rsvp; +import de.fete.domain.model.RsvpToken; +import de.fete.domain.port.out.RsvpRepository; +import org.springframework.stereotype.Repository; + +/** Persistence adapter implementing the RsvpRepository outbound port. */ +@Repository +public class RsvpPersistenceAdapter implements RsvpRepository { + + private final RsvpJpaRepository jpaRepository; + + /** Creates a new adapter with the given JPA repository. */ + public RsvpPersistenceAdapter(RsvpJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Rsvp save(Rsvp rsvp) { + RsvpJpaEntity entity = toEntity(rsvp); + RsvpJpaEntity saved = jpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public long countByEventId(Long eventId) { + return jpaRepository.countByEventId(eventId); + } + + private RsvpJpaEntity toEntity(Rsvp rsvp) { + var entity = new RsvpJpaEntity(); + entity.setId(rsvp.getId()); + entity.setRsvpToken(rsvp.getRsvpToken().value()); + entity.setEventId(rsvp.getEventId()); + entity.setName(rsvp.getName()); + return entity; + } + + private Rsvp toDomain(RsvpJpaEntity entity) { + var rsvp = new Rsvp(); + rsvp.setId(entity.getId()); + rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken())); + rsvp.setEventId(entity.getEventId()); + rsvp.setName(entity.getName()); + return rsvp; + } +} diff --git a/backend/src/main/java/de/fete/application/service/EventExpiredException.java b/backend/src/main/java/de/fete/application/service/EventExpiredException.java new file mode 100644 index 0000000..374830d --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/EventExpiredException.java @@ -0,0 +1,12 @@ +package de.fete.application.service; + +import java.util.UUID; + +/** Thrown when an RSVP is attempted on an expired event. */ +public class EventExpiredException extends RuntimeException { + + /** Creates a new exception for the given event token. */ + public EventExpiredException(UUID eventToken) { + super("Event has expired: " + eventToken); + } +} diff --git a/backend/src/main/java/de/fete/application/service/EventService.java b/backend/src/main/java/de/fete/application/service/EventService.java index 315c5a1..407b5d3 100644 --- a/backend/src/main/java/de/fete/application/service/EventService.java +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -2,6 +2,8 @@ package de.fete.application.service; import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; +import de.fete.domain.model.OrganizerToken; import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.out.EventRepository; @@ -9,7 +11,6 @@ import java.time.Clock; import java.time.LocalDate; import java.time.OffsetDateTime; import java.util.Optional; -import java.util.UUID; import org.springframework.stereotype.Service; /** Application service implementing event creation and retrieval. */ @@ -32,8 +33,8 @@ public class EventService implements CreateEventUseCase, GetEventUseCase { } var event = new Event(); - event.setEventToken(UUID.randomUUID()); - event.setOrganizerToken(UUID.randomUUID()); + event.setEventToken(EventToken.generate()); + event.setOrganizerToken(OrganizerToken.generate()); event.setTitle(command.title()); event.setDescription(command.description()); event.setDateTime(command.dateTime()); @@ -46,7 +47,7 @@ public class EventService implements CreateEventUseCase, GetEventUseCase { } @Override - public Optional getByEventToken(UUID eventToken) { + public Optional getByEventToken(EventToken eventToken) { return eventRepository.findByEventToken(eventToken); } } diff --git a/backend/src/main/java/de/fete/application/service/RsvpService.java b/backend/src/main/java/de/fete/application/service/RsvpService.java new file mode 100644 index 0000000..5153a24 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/RsvpService.java @@ -0,0 +1,43 @@ +package de.fete.application.service; + +import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; +import de.fete.domain.model.Rsvp; +import de.fete.domain.model.RsvpToken; +import de.fete.domain.port.in.CreateRsvpUseCase; +import de.fete.domain.port.out.EventRepository; +import de.fete.domain.port.out.RsvpRepository; +import java.time.Clock; +import org.springframework.stereotype.Service; + +/** Application service implementing RSVP creation. */ +@Service +public class RsvpService implements CreateRsvpUseCase { + + private final EventRepository eventRepository; + private final RsvpRepository rsvpRepository; + private final Clock clock; + + /** Creates a new RsvpService. */ + public RsvpService( + EventRepository eventRepository, + RsvpRepository rsvpRepository, + Clock clock) { + this.eventRepository = eventRepository; + this.rsvpRepository = rsvpRepository; + this.clock = clock; + } + + @Override + public Rsvp createRsvp(EventToken eventToken, String name) { + Event event = eventRepository.findByEventToken(eventToken) + .orElseThrow(() -> new EventNotFoundException(eventToken.value())); + + var rsvp = new Rsvp(); + rsvp.setRsvpToken(RsvpToken.generate()); + rsvp.setEventId(event.getId()); + rsvp.setName(name.strip()); + + return rsvpRepository.save(rsvp); + } +} diff --git a/backend/src/main/java/de/fete/domain/model/Event.java b/backend/src/main/java/de/fete/domain/model/Event.java index 1575137..27d2cf6 100644 --- a/backend/src/main/java/de/fete/domain/model/Event.java +++ b/backend/src/main/java/de/fete/domain/model/Event.java @@ -3,14 +3,13 @@ package de.fete.domain.model; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; -import java.util.UUID; /** Domain entity representing an event. */ public class Event { private Long id; - private UUID eventToken; - private UUID organizerToken; + private EventToken eventToken; + private OrganizerToken organizerToken; private String title; private String description; private OffsetDateTime dateTime; @@ -29,23 +28,23 @@ public class Event { this.id = id; } - /** Returns the public event token (UUID). */ - public UUID getEventToken() { + /** Returns the public event token. */ + public EventToken getEventToken() { return eventToken; } /** Sets the public event token. */ - public void setEventToken(UUID eventToken) { + public void setEventToken(EventToken eventToken) { this.eventToken = eventToken; } - /** Returns the secret organizer token (UUID). */ - public UUID getOrganizerToken() { + /** Returns the secret organizer token. */ + public OrganizerToken getOrganizerToken() { return organizerToken; } /** Sets the secret organizer token. */ - public void setOrganizerToken(UUID organizerToken) { + public void setOrganizerToken(OrganizerToken organizerToken) { this.organizerToken = organizerToken; } diff --git a/backend/src/main/java/de/fete/domain/model/EventToken.java b/backend/src/main/java/de/fete/domain/model/EventToken.java new file mode 100644 index 0000000..f6482ee --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/EventToken.java @@ -0,0 +1,18 @@ +package de.fete.domain.model; + +import java.util.Objects; +import java.util.UUID; + +/** Type-safe wrapper for the public event token. */ +public record EventToken(UUID value) { + + /** Validates that the token value is not null. */ + public EventToken { + Objects.requireNonNull(value, "eventToken must not be null"); + } + + /** Generates a new random event token. */ + public static EventToken generate() { + return new EventToken(UUID.randomUUID()); + } +} diff --git a/backend/src/main/java/de/fete/domain/model/OrganizerToken.java b/backend/src/main/java/de/fete/domain/model/OrganizerToken.java new file mode 100644 index 0000000..8c797fd --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/OrganizerToken.java @@ -0,0 +1,18 @@ +package de.fete.domain.model; + +import java.util.Objects; +import java.util.UUID; + +/** Type-safe wrapper for the secret organizer token. */ +public record OrganizerToken(UUID value) { + + /** Validates that the token value is not null. */ + public OrganizerToken { + Objects.requireNonNull(value, "organizerToken must not be null"); + } + + /** Generates a new random organizer token. */ + public static OrganizerToken generate() { + return new OrganizerToken(UUID.randomUUID()); + } +} diff --git a/backend/src/main/java/de/fete/domain/model/Rsvp.java b/backend/src/main/java/de/fete/domain/model/Rsvp.java new file mode 100644 index 0000000..53285db --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/Rsvp.java @@ -0,0 +1,50 @@ +package de.fete.domain.model; + +/** Domain entity representing an RSVP. */ +public class Rsvp { + + private Long id; + private RsvpToken rsvpToken; + private Long eventId; + private String name; + + /** Returns the internal database ID. */ + public Long getId() { + return id; + } + + /** Sets the internal database ID. */ + public void setId(Long id) { + this.id = id; + } + + /** Returns the RSVP token. */ + public RsvpToken getRsvpToken() { + return rsvpToken; + } + + /** Sets the RSVP token. */ + public void setRsvpToken(RsvpToken rsvpToken) { + this.rsvpToken = rsvpToken; + } + + /** Returns the event ID this RSVP belongs to. */ + public Long getEventId() { + return eventId; + } + + /** Sets the event ID. */ + public void setEventId(Long eventId) { + this.eventId = eventId; + } + + /** Returns the guest's display name. */ + public String getName() { + return name; + } + + /** Sets the guest's display name. */ + public void setName(String name) { + this.name = name; + } +} diff --git a/backend/src/main/java/de/fete/domain/model/RsvpToken.java b/backend/src/main/java/de/fete/domain/model/RsvpToken.java new file mode 100644 index 0000000..769150a --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/RsvpToken.java @@ -0,0 +1,18 @@ +package de.fete.domain.model; + +import java.util.Objects; +import java.util.UUID; + +/** Type-safe wrapper for the RSVP token. */ +public record RsvpToken(UUID value) { + + /** Validates that the token value is not null. */ + public RsvpToken { + Objects.requireNonNull(value, "rsvpToken must not be null"); + } + + /** Generates a new random RSVP token. */ + public static RsvpToken generate() { + return new RsvpToken(UUID.randomUUID()); + } +} diff --git a/backend/src/main/java/de/fete/domain/port/in/CreateRsvpUseCase.java b/backend/src/main/java/de/fete/domain/port/in/CreateRsvpUseCase.java new file mode 100644 index 0000000..5eeef72 --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/in/CreateRsvpUseCase.java @@ -0,0 +1,11 @@ +package de.fete.domain.port.in; + +import de.fete.domain.model.EventToken; +import de.fete.domain.model.Rsvp; + +/** Inbound port for creating a new RSVP. */ +public interface CreateRsvpUseCase { + + /** Creates an RSVP for the given event and guest name. */ + Rsvp createRsvp(EventToken eventToken, String name); +} diff --git a/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java b/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java index 1731e92..2a91b76 100644 --- a/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java +++ b/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java @@ -1,12 +1,12 @@ package de.fete.domain.port.in; import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; import java.util.Optional; -import java.util.UUID; /** Inbound port for retrieving a public event by its token. */ public interface GetEventUseCase { /** Finds an event by its public event token. */ - Optional getByEventToken(UUID eventToken); + Optional getByEventToken(EventToken eventToken); } diff --git a/backend/src/main/java/de/fete/domain/port/out/EventRepository.java b/backend/src/main/java/de/fete/domain/port/out/EventRepository.java index 62db149..84381c2 100644 --- a/backend/src/main/java/de/fete/domain/port/out/EventRepository.java +++ b/backend/src/main/java/de/fete/domain/port/out/EventRepository.java @@ -1,8 +1,8 @@ package de.fete.domain.port.out; import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; import java.util.Optional; -import java.util.UUID; /** Outbound port for persisting and retrieving events. */ public interface EventRepository { @@ -11,5 +11,5 @@ public interface EventRepository { Event save(Event event); /** Finds an event by its public event token. */ - Optional findByEventToken(UUID eventToken); + Optional findByEventToken(EventToken eventToken); } diff --git a/backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java b/backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java new file mode 100644 index 0000000..e2af4fa --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java @@ -0,0 +1,13 @@ +package de.fete.domain.port.out; + +import de.fete.domain.model.Rsvp; + +/** Outbound port for persisting and querying RSVPs. */ +public interface RsvpRepository { + + /** Persists the given RSVP and returns it with generated fields populated. */ + Rsvp save(Rsvp rsvp); + + /** Counts the number of RSVPs for the given event. */ + long countByEventId(Long eventId); +} diff --git a/backend/src/main/resources/db/changelog/003-create-rsvps-table.xml b/backend/src/main/resources/db/changelog/003-create-rsvps-table.xml new file mode 100644 index 0000000..001b32b --- /dev/null +++ b/backend/src/main/resources/db/changelog/003-create-rsvps-table.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index fdd403c..069351a 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -8,5 +8,6 @@ + diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 7108b4c..4356b54 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -37,6 +37,52 @@ paths: schema: $ref: "#/components/schemas/ValidationProblemDetail" + /events/{token}/rsvps: + post: + operationId: createRsvp + summary: Submit an RSVP for an event + tags: + - events + parameters: + - name: token + in: path + required: true + schema: + type: string + format: uuid + description: Public event token + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRsvpRequest" + responses: + "201": + description: RSVP created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRsvpResponse" + "400": + description: Validation failed (e.g. blank name) + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ValidationProblemDetail" + "404": + description: Event not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "409": + description: Event has expired — RSVPs no longer accepted + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + /events/{token}: get: operationId: getEvent @@ -182,6 +228,34 @@ components: description: Whether the event's expiry date has passed example: false + CreateRsvpRequest: + type: object + required: + - name + properties: + name: + type: string + minLength: 1 + maxLength: 100 + description: Guest's display name + example: "Max Mustermann" + + CreateRsvpResponse: + type: object + required: + - rsvpToken + - name + properties: + rsvpToken: + type: string + format: uuid + description: Token identifying this RSVP (store client-side for future updates) + example: "d4e5f6a7-b8c9-0123-4567-890abcdef012" + name: + type: string + description: Guest's display name as stored + example: "Max Mustermann" + ProblemDetail: type: object properties: diff --git a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java index f47f433..70c8518 100644 --- a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java +++ b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java @@ -11,8 +11,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import de.fete.TestcontainersConfig; import de.fete.adapter.in.web.model.CreateEventRequest; import de.fete.adapter.in.web.model.CreateEventResponse; +import de.fete.adapter.in.web.model.CreateRsvpRequest; +import de.fete.adapter.in.web.model.CreateRsvpResponse; import de.fete.adapter.out.persistence.EventJpaEntity; import de.fete.adapter.out.persistence.EventJpaRepository; +import de.fete.adapter.out.persistence.RsvpJpaEntity; +import de.fete.adapter.out.persistence.RsvpJpaRepository; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -39,6 +43,9 @@ class EventControllerIntegrationTest { @Autowired private EventJpaRepository jpaRepository; + @Autowired + private RsvpJpaRepository rsvpJpaRepository; + // --- Create Event tests --- @Test @@ -268,6 +275,80 @@ class EventControllerIntegrationTest { .andExpect(jsonPath("$.expired").value(true)); } + // --- RSVP tests --- + + @Test + void createRsvpReturns201WithToken() throws Exception { + EventJpaEntity event = seedEvent( + "RSVP Event", "Join us!", "Europe/Berlin", + "Berlin", LocalDate.now().plusDays(30)); + + var request = new CreateRsvpRequest().name("Max Mustermann"); + + var result = mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.rsvpToken").isNotEmpty()) + .andExpect(jsonPath("$.name").value("Max Mustermann")) + .andReturn(); + + var response = objectMapper.readValue( + result.getResponse().getContentAsString(), CreateRsvpResponse.class); + + RsvpJpaEntity persisted = rsvpJpaRepository + .findByRsvpToken(response.getRsvpToken()).orElseThrow(); + assertThat(persisted.getName()).isEqualTo("Max Mustermann"); + assertThat(persisted.getEventId()).isEqualTo(event.getId()); + } + + @Test + void createRsvpWithBlankNameReturns400() throws Exception { + EventJpaEntity event = seedEvent( + "RSVP Event", null, "Europe/Berlin", + null, LocalDate.now().plusDays(30)); + + var request = new CreateRsvpRequest().name(""); + + mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")); + } + + @Test + void attendeeCountIncreasesAfterRsvp() throws Exception { + EventJpaEntity event = seedEvent( + "Count Event", null, "Europe/Berlin", + null, LocalDate.now().plusDays(30)); + + mockMvc.perform(get("/api/events/" + event.getEventToken())) + .andExpect(jsonPath("$.attendeeCount").value(0)); + + var request = new CreateRsvpRequest().name("First Guest"); + + mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/events/" + event.getEventToken())) + .andExpect(jsonPath("$.attendeeCount").value(1)); + } + + @Test + void createRsvpForUnknownEventReturns404() throws Exception { + var request = new CreateRsvpRequest().name("Ghost"); + + mockMvc.perform(post("/api/events/" + UUID.randomUUID() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found")); + } + private EventJpaEntity seedEvent( String title, String description, String timezone, String location, LocalDate expiryDate) { diff --git a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java index eedac71..d12c789 100644 --- a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java +++ b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java @@ -4,13 +4,14 @@ import static org.assertj.core.api.Assertions.assertThat; import de.fete.TestcontainersConfig; import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; +import de.fete.domain.model.OrganizerToken; import de.fete.domain.port.out.EventRepository; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Optional; -import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -47,7 +48,7 @@ class EventPersistenceAdapterTest { @Test void findByUnknownEventTokenReturnsEmpty() { - Optional found = eventRepository.findByEventToken(UUID.randomUUID()); + Optional found = eventRepository.findByEventToken(EventToken.generate()); assertThat(found).isEmpty(); } @@ -61,8 +62,8 @@ class EventPersistenceAdapterTest { OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC); var event = new Event(); - event.setEventToken(UUID.randomUUID()); - event.setOrganizerToken(UUID.randomUUID()); + event.setEventToken(EventToken.generate()); + event.setOrganizerToken(OrganizerToken.generate()); event.setTitle("Full Event"); event.setDescription("A detailed description"); event.setDateTime(dateTime); @@ -87,8 +88,8 @@ class EventPersistenceAdapterTest { private Event buildEvent() { var event = new Event(); - event.setEventToken(UUID.randomUUID()); - event.setOrganizerToken(UUID.randomUUID()); + event.setEventToken(EventToken.generate()); + event.setOrganizerToken(OrganizerToken.generate()); event.setTitle("Test Event"); event.setDescription("Test description"); event.setDateTime(OffsetDateTime.now().plusDays(7)); diff --git a/backend/src/test/java/de/fete/application/service/EventServiceTest.java b/backend/src/test/java/de/fete/application/service/EventServiceTest.java index 7062536..eee8920 100644 --- a/backend/src/test/java/de/fete/application/service/EventServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/EventServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.when; import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; import de.fete.domain.port.out.EventRepository; import java.time.Clock; import java.time.Instant; @@ -17,7 +18,6 @@ import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Optional; -import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -130,7 +130,7 @@ class EventServiceTest { @Test void getByEventTokenReturnsEvent() { - UUID token = UUID.randomUUID(); + EventToken token = EventToken.generate(); var event = new Event(); event.setEventToken(token); event.setTitle("Found Event"); @@ -145,7 +145,7 @@ class EventServiceTest { @Test void getByEventTokenReturnsEmptyForUnknownToken() { - UUID token = UUID.randomUUID(); + EventToken token = EventToken.generate(); when(eventRepository.findByEventToken(token)) .thenReturn(Optional.empty()); diff --git a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java new file mode 100644 index 0000000..d5f6f36 --- /dev/null +++ b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java @@ -0,0 +1,115 @@ +package de.fete.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; +import de.fete.domain.model.OrganizerToken; +import de.fete.domain.model.Rsvp; +import de.fete.domain.port.out.EventRepository; +import de.fete.domain.port.out.RsvpRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RsvpServiceTest { + + private static final ZoneId ZONE = ZoneId.of("Europe/Berlin"); + private static final Instant FIXED_INSTANT = + LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant(); + private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE); + + @Mock + private EventRepository eventRepository; + + @Mock + private RsvpRepository rsvpRepository; + + private RsvpService rsvpService; + + @BeforeEach + void setUp() { + rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK); + } + + @Test + void createRsvpSucceedsForActiveEvent() { + Event event = buildActiveEvent(); + EventToken token = event.getEventToken(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); + when(rsvpRepository.save(any(Rsvp.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Rsvp result = rsvpService.createRsvp(token, "Max Mustermann"); + + assertThat(result.getName()).isEqualTo("Max Mustermann"); + assertThat(result.getRsvpToken()).isNotNull(); + assertThat(result.getEventId()).isEqualTo(event.getId()); + } + + @Test + void createRsvpPersistsViaRepository() { + Event event = buildActiveEvent(); + EventToken token = event.getEventToken(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); + when(rsvpRepository.save(any(Rsvp.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + rsvpService.createRsvp(token, "Test Guest"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Rsvp.class); + verify(rsvpRepository).save(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo("Test Guest"); + assertThat(captor.getValue().getEventId()).isEqualTo(event.getId()); + } + + @Test + void createRsvpThrowsWhenEventNotFound() { + EventToken token = EventToken.generate(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> rsvpService.createRsvp(token, "Guest")) + .isInstanceOf(EventNotFoundException.class); + } + + @Test + void createRsvpTrimsName() { + Event event = buildActiveEvent(); + EventToken token = event.getEventToken(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); + when(rsvpRepository.save(any(Rsvp.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Rsvp result = rsvpService.createRsvp(token, " Max "); + + assertThat(result.getName()).isEqualTo("Max"); + } + + private Event buildActiveEvent() { + var event = new Event(); + event.setId(1L); + event.setEventToken(EventToken.generate()); + event.setOrganizerToken(OrganizerToken.generate()); + event.setTitle("Test Event"); + event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); + event.setTimezone(ZONE); + event.setExpiryDate(LocalDate.of(2026, 7, 15)); + event.setCreatedAt(OffsetDateTime.now(FIXED_CLOCK)); + return event; + } +} From fc77248c38a20ae33fed6c84a2eaf48c027a184b Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 12:04:51 +0100 Subject: [PATCH 3/8] Extract CountAttendeesByEventUseCase to decouple controller from repository The EventController was directly accessing RsvpRepository (an outbound port) to count attendees, bypassing the application layer. Introduce a dedicated inbound port and implement it in RsvpService. Remove the now-unused Clock dependency from RsvpService. Co-Authored-By: Claude Opus 4.6 --- .../de/fete/adapter/in/web/EventController.java | 12 ++++++------ .../de/fete/application/service/RsvpService.java | 16 ++++++++++------ .../port/in/CountAttendeesByEventUseCase.java | 10 ++++++++++ .../application/service/RsvpServiceTest.java | 9 ++------- 4 files changed, 28 insertions(+), 19 deletions(-) create mode 100644 backend/src/main/java/de/fete/domain/port/in/CountAttendeesByEventUseCase.java diff --git a/backend/src/main/java/de/fete/adapter/in/web/EventController.java b/backend/src/main/java/de/fete/adapter/in/web/EventController.java index e103ab8..b7c828d 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/EventController.java +++ b/backend/src/main/java/de/fete/adapter/in/web/EventController.java @@ -12,10 +12,10 @@ import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; import de.fete.domain.model.EventToken; import de.fete.domain.model.Rsvp; +import de.fete.domain.port.in.CountAttendeesByEventUseCase; import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.GetEventUseCase; -import de.fete.domain.port.out.RsvpRepository; import java.time.Clock; import java.time.DateTimeException; import java.time.LocalDate; @@ -32,20 +32,20 @@ public class EventController implements EventsApi { private final CreateEventUseCase createEventUseCase; private final GetEventUseCase getEventUseCase; private final CreateRsvpUseCase createRsvpUseCase; - private final RsvpRepository rsvpRepository; + private final CountAttendeesByEventUseCase countAttendeesByEventUseCase; private final Clock clock; - /** Creates a new controller with the given use cases, repository, and clock. */ + /** Creates a new controller with the given use cases and clock. */ public EventController( CreateEventUseCase createEventUseCase, GetEventUseCase getEventUseCase, CreateRsvpUseCase createRsvpUseCase, - RsvpRepository rsvpRepository, + CountAttendeesByEventUseCase countAttendeesByEventUseCase, Clock clock) { this.createEventUseCase = createEventUseCase; this.getEventUseCase = getEventUseCase; this.createRsvpUseCase = createRsvpUseCase; - this.rsvpRepository = rsvpRepository; + this.countAttendeesByEventUseCase = countAttendeesByEventUseCase; this.clock = clock; } @@ -90,7 +90,7 @@ public class EventController implements EventsApi { response.setTimezone(event.getTimezone().getId()); response.setLocation(event.getLocation()); response.setAttendeeCount( - (int) rsvpRepository.countByEventId(event.getId())); + (int) countAttendeesByEventUseCase.countByEvent(eventToken)); response.setExpired( event.getExpiryDate().isBefore(LocalDate.now(clock))); diff --git a/backend/src/main/java/de/fete/application/service/RsvpService.java b/backend/src/main/java/de/fete/application/service/RsvpService.java index 5153a24..65cfe19 100644 --- a/backend/src/main/java/de/fete/application/service/RsvpService.java +++ b/backend/src/main/java/de/fete/application/service/RsvpService.java @@ -4,28 +4,25 @@ import de.fete.domain.model.Event; import de.fete.domain.model.EventToken; import de.fete.domain.model.Rsvp; import de.fete.domain.model.RsvpToken; +import de.fete.domain.port.in.CountAttendeesByEventUseCase; import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.RsvpRepository; -import java.time.Clock; import org.springframework.stereotype.Service; /** Application service implementing RSVP creation. */ @Service -public class RsvpService implements CreateRsvpUseCase { +public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase { private final EventRepository eventRepository; private final RsvpRepository rsvpRepository; - private final Clock clock; /** Creates a new RsvpService. */ public RsvpService( EventRepository eventRepository, - RsvpRepository rsvpRepository, - Clock clock) { + RsvpRepository rsvpRepository) { this.eventRepository = eventRepository; this.rsvpRepository = rsvpRepository; - this.clock = clock; } @Override @@ -40,4 +37,11 @@ public class RsvpService implements CreateRsvpUseCase { return rsvpRepository.save(rsvp); } + + @Override + public long countByEvent(EventToken eventToken) { + Event event = eventRepository.findByEventToken(eventToken) + .orElseThrow(() -> new EventNotFoundException(eventToken.value())); + return rsvpRepository.countByEventId(event.getId()); + } } diff --git a/backend/src/main/java/de/fete/domain/port/in/CountAttendeesByEventUseCase.java b/backend/src/main/java/de/fete/domain/port/in/CountAttendeesByEventUseCase.java new file mode 100644 index 0000000..91c7c96 --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/in/CountAttendeesByEventUseCase.java @@ -0,0 +1,10 @@ +package de.fete.domain.port.in; + +import de.fete.domain.model.EventToken; + +/** Inbound port for counting attendees of an event. */ +public interface CountAttendeesByEventUseCase { + + /** Counts the number of confirmed attendees for the given event. */ + long countByEvent(EventToken eventToken); +} diff --git a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java index d5f6f36..6503e1f 100644 --- a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java @@ -12,8 +12,6 @@ import de.fete.domain.model.OrganizerToken; import de.fete.domain.model.Rsvp; import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.RsvpRepository; -import java.time.Clock; -import java.time.Instant; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; @@ -30,9 +28,6 @@ import org.mockito.junit.jupiter.MockitoExtension; class RsvpServiceTest { private static final ZoneId ZONE = ZoneId.of("Europe/Berlin"); - private static final Instant FIXED_INSTANT = - LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant(); - private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE); @Mock private EventRepository eventRepository; @@ -44,7 +39,7 @@ class RsvpServiceTest { @BeforeEach void setUp() { - rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK); + rsvpService = new RsvpService(eventRepository, rsvpRepository); } @Test @@ -109,7 +104,7 @@ class RsvpServiceTest { event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); event.setTimezone(ZONE); event.setExpiryDate(LocalDate.of(2026, 7, 15)); - event.setCreatedAt(OffsetDateTime.now(FIXED_CLOCK)); + event.setCreatedAt(OffsetDateTime.now()); return event; } } From e248a2ee06d9e66869bbd2c4e772171308a449ac Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 12:05:00 +0100 Subject: [PATCH 4/8] Add ArchUnit rule: web adapter must not depend on outbound ports Prevents future regressions where controllers bypass the application layer and access repositories directly. Co-Authored-By: Claude Opus 4.6 --- backend/src/test/java/de/fete/HexagonalArchitectureTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/test/java/de/fete/HexagonalArchitectureTest.java b/backend/src/test/java/de/fete/HexagonalArchitectureTest.java index fbcba99..90e2124 100644 --- a/backend/src/test/java/de/fete/HexagonalArchitectureTest.java +++ b/backend/src/test/java/de/fete/HexagonalArchitectureTest.java @@ -60,4 +60,9 @@ class HexagonalArchitectureTest { static final ArchRule persistenceMustNotDependOnWeb = noClasses() .that().resideInAPackage("de.fete.adapter.out.persistence..") .should().dependOnClassesThat().resideInAPackage("de.fete.adapter.in.web.."); + + @ArchTest + static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses() + .that().resideInAPackage("de.fete.adapter.in.web..") + .should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out.."); } From d9136481d83fbe9024ace5de0552b205ac4db5fd Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 12:05:09 +0100 Subject: [PATCH 5/8] Run mvnw verify instead of test in stop hook to include SpotBugs Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/run-tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/hooks/run-tests.sh b/.claude/hooks/run-tests.sh index e2f368e..eb3279c 100755 --- a/.claude/hooks/run-tests.sh +++ b/.claude/hooks/run-tests.sh @@ -26,7 +26,7 @@ PASSED="" # Run backend tests if Java sources changed if [[ -n "$HAS_BACKEND" ]]; then - if OUTPUT=$(cd backend && ./mvnw test -q 2>&1); then + if OUTPUT=$(cd backend && ./mvnw verify -q 2>&1); then PASSED+="✓ Backend tests passed. " else # Filter: only [ERROR] lines, skip Maven boilerplate From be1c5062a2bb23dca2cdef92290126da656256de Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 12:47:53 +0100 Subject: [PATCH 6/8] Add RSVP frontend: bottom sheet form, RsvpBar, and localStorage persistence Introduces BottomSheet and RsvpBar components, integrates the RSVP submission flow into EventDetailView, extends useEventStorage with saveRsvp/getRsvp, and adds unit tests plus an E2E spec for the RSVP workflow. Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/event-rsvp.spec.ts | 185 +++++++++++++ frontend/src/assets/main.css | 31 +++ frontend/src/components/BottomSheet.vue | 96 +++++++ frontend/src/components/RsvpBar.vue | 75 ++++++ .../components/__tests__/BottomSheet.spec.ts | 51 ++++ .../src/components/__tests__/RsvpBar.spec.ts | 30 +++ .../__tests__/useEventStorage.spec.ts | 48 ++++ frontend/src/composables/useEventStorage.ts | 24 +- frontend/src/views/EventDetailView.vue | 103 +++++++ .../views/__tests__/EventCreateView.spec.ts | 4 + .../views/__tests__/EventDetailView.spec.ts | 251 +++++++++++++++--- 11 files changed, 856 insertions(+), 42 deletions(-) create mode 100644 frontend/e2e/event-rsvp.spec.ts create mode 100644 frontend/src/components/BottomSheet.vue create mode 100644 frontend/src/components/RsvpBar.vue create mode 100644 frontend/src/components/__tests__/BottomSheet.spec.ts create mode 100644 frontend/src/components/__tests__/RsvpBar.spec.ts diff --git a/frontend/e2e/event-rsvp.spec.ts b/frontend/e2e/event-rsvp.spec.ts new file mode 100644 index 0000000..c954e5b --- /dev/null +++ b/frontend/e2e/event-rsvp.spec.ts @@ -0,0 +1,185 @@ +import { http, HttpResponse } from 'msw' +import { test, expect } from './msw-setup' + +const fullEvent = { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + title: 'Summer BBQ', + description: 'Bring your own drinks!', + dateTime: '2026-03-15T20:00:00+01:00', + timezone: 'Europe/Berlin', + location: 'Central Park, NYC', + attendeeCount: 12, + expired: false, +} + +test.describe('US1: RSVP submission flow', () => { + test('submits RSVP, updates attendee count, and persists in localStorage', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + http.post('*/api/events/:token/rsvps', () => { + return HttpResponse.json( + { rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012', name: 'Max Mustermann' }, + { status: 201 }, + ) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + + // CTA is visible + const cta = page.getByRole('button', { name: "I'm attending" }) + await expect(cta).toBeVisible() + + // Open bottom sheet + await cta.click() + const dialog = page.getByRole('dialog', { name: 'RSVP' }) + await expect(dialog).toBeVisible() + + // Fill name and submit + await dialog.getByLabel('Your name').fill('Max Mustermann') + await dialog.getByRole('button', { name: 'Count me in' }).click() + + // Bottom sheet closes, status bar appears + await expect(dialog).not.toBeVisible() + await expect(page.getByText("You're attending!")).toBeVisible() + await expect(cta).not.toBeVisible() + + // Attendee count incremented + await expect(page.getByText('13')).toBeVisible() + + // Verify localStorage + const stored = await page.evaluate(() => { + const raw = localStorage.getItem('fete:events') + return raw ? JSON.parse(raw) : null + }) + expect(stored).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012', + rsvpName: 'Max Mustermann', + }), + ]), + ) + }) + + test('shows validation error when name is empty', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + await page.getByRole('button', { name: "I'm attending" }).click() + + const dialog = page.getByRole('dialog', { name: 'RSVP' }) + await dialog.getByRole('button', { name: 'Count me in' }).click() + + await expect(page.getByText('Please enter your name.')).toBeVisible() + }) + + test('restores RSVP status from localStorage on page load', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + ) + + // Pre-seed localStorage + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem( + 'fete:events', + JSON.stringify([ + { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + title: 'Summer BBQ', + dateTime: '2026-03-15T20:00:00+01:00', + expiryDate: '', + rsvpToken: 'existing-rsvp-token', + rsvpName: 'Anna', + }, + ]), + ) + }) + + await page.goto(`/events/${fullEvent.eventToken}`) + + // Status bar should show, not CTA + await expect(page.getByText("You're attending!")).toBeVisible() + await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible() + }) + + test('shows error when server is unreachable during RSVP', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + http.post('*/api/events/:token/rsvps', () => { + return HttpResponse.json( + { type: 'about:blank', title: 'Bad Request', status: 400 }, + { status: 400, headers: { 'Content-Type': 'application/problem+json' } }, + ) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + await page.getByRole('button', { name: "I'm attending" }).click() + + const dialog = page.getByRole('dialog', { name: 'RSVP' }) + await dialog.getByLabel('Your name').fill('Max') + await dialog.getByRole('button', { name: 'Count me in' }).click() + + await expect(page.getByText('Could not submit RSVP. Please try again.')).toBeVisible() + }) + + test('does not show RSVP bar for organizer', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json(fullEvent) + }), + ) + + // Pre-seed localStorage with organizer token + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem( + 'fete:events', + JSON.stringify([ + { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + organizerToken: 'org-token-123', + title: 'Summer BBQ', + dateTime: '2026-03-15T20:00:00+01:00', + expiryDate: '', + }, + ]), + ) + }) + + await page.goto(`/events/${fullEvent.eventToken}`) + + // Event content should load + await expect(page.getByText('Summer BBQ')).toBeVisible() + + // But no RSVP bar + await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible() + await expect(page.getByText("You're attending!")).not.toBeVisible() + }) + + test('does not show RSVP bar on expired event', async ({ page, network }) => { + network.use( + http.get('*/api/events/:token', () => { + return HttpResponse.json({ ...fullEvent, expired: true }) + }), + ) + + await page.goto(`/events/${fullEvent.eventToken}`) + + await expect(page.getByText('This event has ended.')).toBeVisible() + await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible() + }) +}) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 2da8244..e054431 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -192,3 +192,34 @@ textarea.form-field { white-space: nowrap; border: 0; } + +/* Bottom sheet form */ +.sheet-title { + font-size: 1.2rem; + font-weight: 700; + color: var(--color-text); +} + +.rsvp-form { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.rsvp-form__label { + font-size: 0.85rem; + font-weight: 700; + color: var(--color-text); + padding-left: 0.25rem; +} + +.rsvp-form__field-error { + color: #d32f2f; + font-size: 0.875rem; + font-weight: 600; + padding-left: 0.25rem; +} + +.rsvp-form__error { + text-align: center; +} diff --git a/frontend/src/components/BottomSheet.vue b/frontend/src/components/BottomSheet.vue new file mode 100644 index 0000000..c3a0ba6 --- /dev/null +++ b/frontend/src/components/BottomSheet.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/frontend/src/components/RsvpBar.vue b/frontend/src/components/RsvpBar.vue new file mode 100644 index 0000000..76f4d59 --- /dev/null +++ b/frontend/src/components/RsvpBar.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/components/__tests__/BottomSheet.spec.ts b/frontend/src/components/__tests__/BottomSheet.spec.ts new file mode 100644 index 0000000..383f513 --- /dev/null +++ b/frontend/src/components/__tests__/BottomSheet.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import BottomSheet from '../BottomSheet.vue' + +function mountSheet(open = true) { + return mount(BottomSheet, { + props: { open, label: 'Test Sheet' }, + slots: { default: '

Sheet content

' }, + attachTo: document.body, + }) +} + +describe('BottomSheet', () => { + it('renders slot content when open', () => { + const wrapper = mountSheet(true) + expect(document.body.textContent).toContain('Sheet content') + wrapper.unmount() + }) + + it('does not render content when closed', () => { + const wrapper = mountSheet(false) + expect(document.body.querySelector('[role="dialog"]')).toBeNull() + wrapper.unmount() + }) + + it('has aria-modal and aria-label on the dialog', () => { + const wrapper = mountSheet(true) + const dialog = document.body.querySelector('[role="dialog"]')! + expect(dialog.getAttribute('aria-modal')).toBe('true') + expect(dialog.getAttribute('aria-label')).toBe('Test Sheet') + wrapper.unmount() + }) + + it('emits close when backdrop is clicked', async () => { + const wrapper = mountSheet(true) + const backdrop = document.body.querySelector('.sheet-backdrop')! as HTMLElement + await backdrop.click() + // Vue test utils tracks emitted events on the wrapper + expect(wrapper.emitted('close')).toBeTruthy() + wrapper.unmount() + }) + + it('emits close on Escape key', async () => { + const wrapper = mountSheet(true) + const backdrop = document.body.querySelector('.sheet-backdrop')! as HTMLElement + backdrop.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('close')).toBeTruthy() + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/__tests__/RsvpBar.spec.ts b/frontend/src/components/__tests__/RsvpBar.spec.ts new file mode 100644 index 0000000..b9aa1d9 --- /dev/null +++ b/frontend/src/components/__tests__/RsvpBar.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import RsvpBar from '../RsvpBar.vue' + +describe('RsvpBar', () => { + it('renders CTA button when hasRsvp is false', () => { + const wrapper = mount(RsvpBar) + expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true) + expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending") + expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false) + }) + + it('renders status text when hasRsvp is true', () => { + const wrapper = mount(RsvpBar, { props: { hasRsvp: true } }) + expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true) + expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!") + expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false) + }) + + it('emits open when CTA button is clicked', async () => { + const wrapper = mount(RsvpBar) + await wrapper.find('.rsvp-bar__cta').trigger('click') + expect(wrapper.emitted('open')).toHaveLength(1) + }) + + it('does not render CTA button when hasRsvp is true', () => { + const wrapper = mount(RsvpBar, { props: { hasRsvp: true } }) + expect(wrapper.find('button').exists()).toBe(false) + }) +}) diff --git a/frontend/src/composables/__tests__/useEventStorage.spec.ts b/frontend/src/composables/__tests__/useEventStorage.spec.ts index 98518a2..3077c5f 100644 --- a/frontend/src/composables/__tests__/useEventStorage.spec.ts +++ b/frontend/src/composables/__tests__/useEventStorage.spec.ts @@ -116,4 +116,52 @@ describe('useEventStorage', () => { expect(events).toHaveLength(1) expect(events[0]!.title).toBe('New Title') }) + + it('saves and retrieves RSVP for an existing event', () => { + const { saveCreatedEvent, saveRsvp, getRsvp } = useEventStorage() + + saveCreatedEvent({ + eventToken: 'abc-123', + title: 'Birthday', + dateTime: '2026-06-15T20:00:00+02:00', + expiryDate: '2026-07-15', + }) + + saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00') + + const rsvp = getRsvp('abc-123') + expect(rsvp).toEqual({ rsvpToken: 'rsvp-token-1', rsvpName: 'Max' }) + }) + + it('saves RSVP for a new event (not previously stored)', () => { + const { saveRsvp, getRsvp, getStoredEvents } = useEventStorage() + + saveRsvp('new-event', 'rsvp-token-2', 'Anna', 'Party', '2026-08-01T18:00:00+02:00') + + const rsvp = getRsvp('new-event') + expect(rsvp).toEqual({ rsvpToken: 'rsvp-token-2', rsvpName: 'Anna' }) + + const events = getStoredEvents() + expect(events).toHaveLength(1) + expect(events[0]!.eventToken).toBe('new-event') + expect(events[0]!.title).toBe('Party') + }) + + it('returns undefined RSVP for event without RSVP', () => { + const { saveCreatedEvent, getRsvp } = useEventStorage() + + saveCreatedEvent({ + eventToken: 'abc-123', + title: 'Test', + dateTime: '2026-06-15T20:00:00+02:00', + expiryDate: '2026-07-15', + }) + + expect(getRsvp('abc-123')).toBeUndefined() + }) + + it('returns undefined RSVP for unknown event', () => { + const { getRsvp } = useEventStorage() + expect(getRsvp('unknown')).toBeUndefined() + }) }) diff --git a/frontend/src/composables/useEventStorage.ts b/frontend/src/composables/useEventStorage.ts index e5e062f..8acbdc1 100644 --- a/frontend/src/composables/useEventStorage.ts +++ b/frontend/src/composables/useEventStorage.ts @@ -4,6 +4,8 @@ export interface StoredEvent { title: string dateTime: string expiryDate: string + rsvpToken?: string + rsvpName?: string } const STORAGE_KEY = 'fete:events' @@ -37,5 +39,25 @@ export function useEventStorage() { return event?.organizerToken } - return { saveCreatedEvent, getStoredEvents, getOrganizerToken } + function saveRsvp(eventToken: string, rsvpToken: string, rsvpName: string, title: string, dateTime: string): void { + const events = readEvents() + const existing = events.find((e) => e.eventToken === eventToken) + if (existing) { + existing.rsvpToken = rsvpToken + existing.rsvpName = rsvpName + } else { + events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName }) + } + writeEvents(events) + } + + function getRsvp(eventToken: string): { rsvpToken: string; rsvpName: string } | undefined { + const event = readEvents().find((e) => e.eventToken === eventToken) + if (event?.rsvpToken && event?.rsvpName) { + return { rsvpToken: event.rsvpToken, rsvpName: event.rsvpName } + } + return undefined + } + + return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp } } diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue index b946f98..812c0c7 100644 --- a/frontend/src/views/EventDetailView.vue +++ b/frontend/src/views/EventDetailView.vue @@ -54,6 +54,38 @@

Something went wrong.

+ + + + + + +

RSVP

+
+
+ + + {{ nameError }} +
+ + +
+
@@ -61,15 +93,29 @@ import { ref, computed, onMounted } from 'vue' import { RouterLink, useRoute } from 'vue-router' import { api } from '@/api/client' +import { useEventStorage } from '@/composables/useEventStorage' +import BottomSheet from '@/components/BottomSheet.vue' +import RsvpBar from '@/components/RsvpBar.vue' import type { components } from '@/api/schema' type GetEventResponse = components['schemas']['GetEventResponse'] type State = 'loading' | 'loaded' | 'not-found' | 'error' const route = useRoute() +const { saveRsvp, getRsvp, getOrganizerToken } = useEventStorage() + const state = ref('loading') const event = ref(null) +// RSVP state +const sheetOpen = ref(false) +const nameInput = ref('') +const nameError = ref('') +const submitError = ref('') +const submitting = ref(false) +const rsvpName = ref(undefined) +const isOrganizer = ref(false) + const formattedDateTime = computed(() => { if (!event.value) return '' const formatted = new Intl.DateTimeFormat(undefined, { @@ -95,11 +141,68 @@ async function fetchEvent() { event.value = data! state.value = 'loaded' + + // Check if current user is the organizer + isOrganizer.value = !!getOrganizerToken(event.value.eventToken) + + // Restore RSVP status from localStorage + const stored = getRsvp(event.value.eventToken) + if (stored) { + rsvpName.value = stored.rsvpName + } } catch { state.value = 'error' } } +async function submitRsvp() { + nameError.value = '' + submitError.value = '' + + if (!nameInput.value) { + nameError.value = 'Please enter your name.' + return + } + + if (nameInput.value.length > 100) { + nameError.value = 'Name must be 100 characters or fewer.' + return + } + + submitting.value = true + + try { + const { data, error } = await api.POST('/events/{token}/rsvps', { + params: { path: { token: route.params.token as string } }, + body: { name: nameInput.value }, + }) + + if (error) { + submitError.value = 'Could not submit RSVP. Please try again.' + return + } + + // Persist RSVP in localStorage + saveRsvp( + event.value!.eventToken, + data!.rsvpToken, + data!.name, + event.value!.title, + event.value!.dateTime, + ) + + // Update UI + rsvpName.value = data!.name + event.value!.attendeeCount += 1 + sheetOpen.value = false + nameInput.value = '' + } catch { + submitError.value = 'Could not submit RSVP. Please try again.' + } finally { + submitting.value = false + } +} + onMounted(fetchEvent) diff --git a/frontend/src/views/__tests__/EventCreateView.spec.ts b/frontend/src/views/__tests__/EventCreateView.spec.ts index 447de76..441a2a1 100644 --- a/frontend/src/views/__tests__/EventCreateView.spec.ts +++ b/frontend/src/views/__tests__/EventCreateView.spec.ts @@ -14,6 +14,8 @@ vi.mock('@/composables/useEventStorage', () => ({ saveCreatedEvent: vi.fn(), getStoredEvents: vi.fn(() => []), getOrganizerToken: vi.fn(), + saveRsvp: vi.fn(), + getRsvp: vi.fn(), })), })) @@ -165,6 +167,8 @@ describe('EventCreateView', () => { saveCreatedEvent: mockSave, getStoredEvents: vi.fn(() => []), getOrganizerToken: vi.fn(), + saveRsvp: vi.fn(), + getRsvp: vi.fn(), }) vi.mocked(api.POST).mockResolvedValueOnce({ diff --git a/frontend/src/views/__tests__/EventDetailView.spec.ts b/frontend/src/views/__tests__/EventDetailView.spec.ts index fddc8dd..2a1f30e 100644 --- a/frontend/src/views/__tests__/EventDetailView.spec.ts +++ b/frontend/src/views/__tests__/EventDetailView.spec.ts @@ -7,9 +7,24 @@ import { api } from '@/api/client' vi.mock('@/api/client', () => ({ api: { GET: vi.fn(), + POST: vi.fn(), }, })) +const mockSaveRsvp = vi.fn() +const mockGetRsvp = vi.fn() +const mockGetOrganizerToken = vi.fn() + +vi.mock('@/composables/useEventStorage', () => ({ + useEventStorage: vi.fn(() => ({ + saveCreatedEvent: vi.fn(), + getStoredEvents: vi.fn(() => []), + getOrganizerToken: mockGetOrganizerToken, + saveRsvp: mockSaveRsvp, + getRsvp: mockGetRsvp, + })), +})) + function createTestRouter(_token?: string) { return createRouter({ history: createMemoryHistory(), @@ -26,6 +41,7 @@ async function mountWithToken(token = 'test-token') { await router.isReady() return mount(EventDetailView, { global: { plugins: [router] }, + attachTo: document.body, }) } @@ -40,12 +56,22 @@ const fullEvent = { expired: false, } +function mockLoadedEvent(eventOverrides = {}) { + vi.mocked(api.GET).mockResolvedValue({ + data: { ...fullEvent, ...eventOverrides }, + error: undefined, + response: new Response(null, { status: 200 }), + } as never) +} + beforeEach(() => { vi.restoreAllMocks() + mockGetRsvp.mockReturnValue(undefined) + mockGetOrganizerToken.mockReturnValue(undefined) }) describe('EventDetailView', () => { - // T014: Loading state + // Loading state it('renders skeleton shimmer placeholders while loading', async () => { vi.mocked(api.GET).mockReturnValue(new Promise(() => {})) @@ -53,15 +79,12 @@ describe('EventDetailView', () => { expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true) expect(wrapper.findAll('.skeleton').length).toBeGreaterThanOrEqual(3) + wrapper.unmount() }) - // T013: Loaded state — all fields + // Loaded state — all fields it('renders all event fields when loaded', async () => { - vi.mocked(api.GET).mockResolvedValue({ - data: fullEvent, - error: undefined, - response: new Response(null, { status: 200 }), - } as never) + mockLoadedEvent() const wrapper = await mountWithToken() await flushPromises() @@ -71,37 +94,25 @@ describe('EventDetailView', () => { expect(wrapper.text()).toContain('Central Park, NYC') expect(wrapper.text()).toContain('12') expect(wrapper.text()).toContain('Europe/Berlin') + wrapper.unmount() }) - // T013: Loaded state — locale-formatted date/time + // Loaded state — locale-formatted date/time it('formats date/time with Intl.DateTimeFormat and timezone', async () => { - vi.mocked(api.GET).mockResolvedValue({ - data: fullEvent, - error: undefined, - response: new Response(null, { status: 200 }), - } as never) + mockLoadedEvent() const wrapper = await mountWithToken() await flushPromises() const dateField = wrapper.findAll('.detail__value')[0]! expect(dateField.text()).toContain('(Europe/Berlin)') - // The formatted date part is locale-dependent but should contain the year expect(dateField.text()).toContain('2026') + wrapper.unmount() }) - // T013: Loaded state — optional fields absent + // Loaded state — optional fields absent it('does not render description and location when absent', async () => { - vi.mocked(api.GET).mockResolvedValue({ - data: { - ...fullEvent, - description: undefined, - location: undefined, - attendeeCount: 0, - }, - error: undefined, - response: new Response(null, { status: 200 }), - } as never) + mockLoadedEvent({ description: undefined, location: undefined, attendeeCount: 0 }) const wrapper = await mountWithToken() await flushPromises() @@ -109,38 +120,33 @@ describe('EventDetailView', () => { expect(wrapper.text()).not.toContain('Description') expect(wrapper.text()).not.toContain('Location') expect(wrapper.text()).toContain('0') + wrapper.unmount() }) - // T020 (US2): Expired state + // Expired state it('renders "event has ended" banner when expired', async () => { - vi.mocked(api.GET).mockResolvedValue({ - data: { ...fullEvent, expired: true }, - error: undefined, - response: new Response(null, { status: 200 }), - } as never) + mockLoadedEvent({ expired: true }) const wrapper = await mountWithToken() await flushPromises() expect(wrapper.text()).toContain('This event has ended.') expect(wrapper.find('.detail__banner--expired').exists()).toBe(true) + wrapper.unmount() }) - // T020 (US2): No expired banner when not expired + // No expired banner when not expired it('does not render expired banner when event is active', async () => { - vi.mocked(api.GET).mockResolvedValue({ - data: fullEvent, - error: undefined, - response: new Response(null, { status: 200 }), - } as never) + mockLoadedEvent() const wrapper = await mountWithToken() await flushPromises() expect(wrapper.find('.detail__banner--expired').exists()).toBe(false) + wrapper.unmount() }) - // T023 (US4): Not found state + // Not found state it('renders "event not found" when API returns 404', async () => { vi.mocked(api.GET).mockResolvedValue({ data: undefined, @@ -152,11 +158,11 @@ describe('EventDetailView', () => { await flushPromises() expect(wrapper.text()).toContain('Event not found.') - // No event data in DOM expect(wrapper.find('.detail__title').exists()).toBe(false) + wrapper.unmount() }) - // T027: Server error + retry + // Server error + retry it('renders error state with retry button on server error', async () => { vi.mocked(api.GET).mockResolvedValue({ data: undefined, @@ -169,9 +175,10 @@ describe('EventDetailView', () => { expect(wrapper.text()).toContain('Something went wrong.') expect(wrapper.find('button').text()).toBe('Retry') + wrapper.unmount() }) - // T027: Retry button re-fetches + // Retry button re-fetches it('retry button triggers a new fetch', async () => { vi.mocked(api.GET) .mockResolvedValueOnce({ @@ -194,5 +201,167 @@ describe('EventDetailView', () => { await flushPromises() expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ') + wrapper.unmount() + }) + + // RSVP bar + it('shows RSVP CTA bar on active event', async () => { + mockLoadedEvent() + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true) + expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending") + wrapper.unmount() + }) + + it('does not show RSVP bar for organizer', async () => { + mockGetOrganizerToken.mockReturnValue('org-token-123') + mockLoadedEvent() + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.find('.rsvp-bar').exists()).toBe(false) + expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false) + wrapper.unmount() + }) + + it('does not show RSVP bar on expired event', async () => { + mockLoadedEvent({ expired: true }) + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false) + expect(wrapper.find('.rsvp-bar').exists()).toBe(false) + wrapper.unmount() + }) + + it('shows RSVP status bar when localStorage has RSVP', async () => { + mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' }) + mockLoadedEvent() + + const wrapper = await mountWithToken() + await flushPromises() + + expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true) + expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!") + expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false) + wrapper.unmount() + }) + + // RSVP form submission + it('opens bottom sheet when CTA is clicked', async () => { + mockLoadedEvent() + + const wrapper = await mountWithToken() + await flushPromises() + + expect(document.body.querySelector('[role="dialog"]')).toBeNull() + + await wrapper.find('.rsvp-bar__cta').trigger('click') + await flushPromises() + + expect(document.body.querySelector('[role="dialog"]')).not.toBeNull() + wrapper.unmount() + }) + + it('shows validation error when submitting empty name', async () => { + mockLoadedEvent() + + const wrapper = await mountWithToken() + await flushPromises() + + await wrapper.find('.rsvp-bar__cta').trigger('click') + await flushPromises() + + // Form is inside Teleport — find via document.body + const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement + form.dispatchEvent(new Event('submit', { bubbles: true })) + await flushPromises() + + expect(document.body.querySelector('.rsvp-form__field-error')?.textContent).toBe('Please enter your name.') + expect(vi.mocked(api.POST)).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('submits RSVP, saves to storage, and shows status', async () => { + mockLoadedEvent() + vi.mocked(api.POST).mockResolvedValue({ + data: { rsvpToken: 'rsvp-token-1', name: 'Max' }, + error: undefined, + response: new Response(null, { status: 201 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + // Open sheet + await wrapper.find('.rsvp-bar__cta').trigger('click') + await flushPromises() + + // Fill name via Teleported input + const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement + input.value = 'Max' + input.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + + // Submit form + const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement + form.dispatchEvent(new Event('submit', { bubbles: true })) + await flushPromises() + + // Verify API call + expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{token}/rsvps', { + params: { path: { token: 'test-token' } }, + body: { name: 'Max' }, + }) + + // Verify storage + expect(mockSaveRsvp).toHaveBeenCalledWith( + 'abc-123', + 'rsvp-token-1', + 'Max', + 'Summer BBQ', + '2026-03-15T20:00:00+01:00', + ) + + // Verify UI switched to status + expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!") + expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false) + + // Verify attendee count incremented + expect(wrapper.text()).toContain('13') + + wrapper.unmount() + }) + + it('shows error when RSVP submission fails', async () => { + mockLoadedEvent() + vi.mocked(api.POST).mockResolvedValue({ + data: undefined, + error: { type: 'about:blank', title: 'Bad Request', status: 400 }, + response: new Response(null, { status: 400 }), + } as never) + + const wrapper = await mountWithToken() + await flushPromises() + + await wrapper.find('.rsvp-bar__cta').trigger('click') + await flushPromises() + + const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement + input.value = 'Max' + input.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + + const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement + form.dispatchEvent(new Event('submit', { bubbles: true })) + await flushPromises() + + expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.') + wrapper.unmount() }) }) From 4d6df8d16b643772c6189d0db7eb1ff336b75211 Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 13:00:30 +0100 Subject: [PATCH 7/8] Block RSVPs on expired events with 409 Conflict and inject Clock into RsvpService Adds expiry check to RsvpService using an injected Clock for testability, handles EventExpiredException in GlobalExceptionHandler as 409 Conflict, and adds unit + integration tests using relative dates from a fixed clock. Co-Authored-By: Claude Opus 4.6 --- .../in/web/GlobalExceptionHandler.java | 14 +++++++++ .../fete/application/service/RsvpService.java | 11 ++++++- .../web/EventControllerIntegrationTest.java | 16 ++++++++++ .../application/service/RsvpServiceTest.java | 31 +++++++++++++++++-- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java index 34c9726..33e3f24 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package de.fete.adapter.in.web; +import de.fete.application.service.EventExpiredException; import de.fete.application.service.EventNotFoundException; import de.fete.application.service.ExpiryDateInPastException; import de.fete.application.service.InvalidTimezoneException; @@ -59,6 +60,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { .body(problemDetail); } + /** Handles RSVP on expired event. */ + @ExceptionHandler(EventExpiredException.class) + public ResponseEntity handleEventExpired( + EventExpiredException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, ex.getMessage()); + problemDetail.setTitle("Event Expired"); + problemDetail.setType(URI.create("urn:problem-type:event-expired")); + return ResponseEntity.status(HttpStatus.CONFLICT) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } + /** Handles event not found. */ @ExceptionHandler(EventNotFoundException.class) public ResponseEntity handleEventNotFound( diff --git a/backend/src/main/java/de/fete/application/service/RsvpService.java b/backend/src/main/java/de/fete/application/service/RsvpService.java index 65cfe19..0790d17 100644 --- a/backend/src/main/java/de/fete/application/service/RsvpService.java +++ b/backend/src/main/java/de/fete/application/service/RsvpService.java @@ -8,6 +8,8 @@ import de.fete.domain.port.in.CountAttendeesByEventUseCase; import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.RsvpRepository; +import java.time.Clock; +import java.time.LocalDate; import org.springframework.stereotype.Service; /** Application service implementing RSVP creation. */ @@ -16,13 +18,16 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC private final EventRepository eventRepository; private final RsvpRepository rsvpRepository; + private final Clock clock; /** Creates a new RsvpService. */ public RsvpService( EventRepository eventRepository, - RsvpRepository rsvpRepository) { + RsvpRepository rsvpRepository, + Clock clock) { this.eventRepository = eventRepository; this.rsvpRepository = rsvpRepository; + this.clock = clock; } @Override @@ -30,6 +35,10 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC Event event = eventRepository.findByEventToken(eventToken) .orElseThrow(() -> new EventNotFoundException(eventToken.value())); + if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) { + throw new EventExpiredException(eventToken.value()); + } + var rsvp = new Rsvp(); rsvp.setRsvpToken(RsvpToken.generate()); rsvp.setEventId(event.getId()); diff --git a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java index 70c8518..ed1242a 100644 --- a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java +++ b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java @@ -349,6 +349,22 @@ class EventControllerIntegrationTest { .andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found")); } + @Test + void createRsvpForExpiredEventReturns409() throws Exception { + EventJpaEntity event = seedEvent( + "Expired Party", null, "Europe/Berlin", + null, LocalDate.now().minusDays(1)); + + var request = new CreateRsvpRequest().name("Late Guest"); + + mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:event-expired")); + } + private EventJpaEntity seedEvent( String title, String description, String timezone, String location, LocalDate expiryDate) { diff --git a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java index 6503e1f..21e9296 100644 --- a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java @@ -12,6 +12,8 @@ import de.fete.domain.model.OrganizerToken; import de.fete.domain.model.Rsvp; import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.RsvpRepository; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; @@ -28,6 +30,9 @@ import org.mockito.junit.jupiter.MockitoExtension; class RsvpServiceTest { private static final ZoneId ZONE = ZoneId.of("Europe/Berlin"); + private static final Instant NOW = Instant.parse("2026-03-08T12:00:00Z"); + private static final Clock FIXED_CLOCK = Clock.fixed(NOW, ZONE); + private static final LocalDate TODAY = LocalDate.ofInstant(NOW, ZONE); @Mock private EventRepository eventRepository; @@ -39,7 +44,7 @@ class RsvpServiceTest { @BeforeEach void setUp() { - rsvpService = new RsvpService(eventRepository, rsvpRepository); + rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK); } @Test @@ -95,6 +100,28 @@ class RsvpServiceTest { assertThat(result.getName()).isEqualTo("Max"); } + @Test + void createRsvpThrowsWhenEventExpired() { + var event = buildActiveEvent(); + event.setExpiryDate(TODAY.minusDays(1)); + EventToken token = event.getEventToken(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); + + assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest")) + .isInstanceOf(EventExpiredException.class); + } + + @Test + void createRsvpThrowsWhenEventExpiresToday() { + var event = buildActiveEvent(); + event.setExpiryDate(TODAY); + EventToken token = event.getEventToken(); + when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); + + assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest")) + .isInstanceOf(EventExpiredException.class); + } + private Event buildActiveEvent() { var event = new Event(); event.setId(1L); @@ -103,7 +130,7 @@ class RsvpServiceTest { event.setTitle("Test Event"); event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); event.setTimezone(ZONE); - event.setExpiryDate(LocalDate.of(2026, 7, 15)); + event.setExpiryDate(TODAY.plusDays(30)); event.setCreatedAt(OffsetDateTime.now()); return event; } From 90bfd12bf3990bcefb60ceb49bbb2ee3d372eadf Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 13:22:10 +0100 Subject: [PATCH 8/8] Validate expiryDate is strictly after eventDate and harden rejection tests Adds ExpiryDateBeforeEventException (400) when expiryDate <= eventDate, asserts DB row count unchanged after every rejection in integration tests, and replaces all hardcoded dates in EventServiceTest with TODAY-relative expressions derived from the fixed Clock. Co-Authored-By: Claude Opus 4.6 --- .../in/web/GlobalExceptionHandler.java | 14 +++ .../application/service/EventService.java | 4 + .../ExpiryDateBeforeEventException.java | 13 +++ .../web/EventControllerIntegrationTest.java | 86 +++++++++++++++++-- .../application/service/EventServiceTest.java | 77 +++++++++++++---- 5 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java diff --git a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java index 33e3f24..76e5630 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ package de.fete.adapter.in.web; import de.fete.application.service.EventExpiredException; import de.fete.application.service.EventNotFoundException; +import de.fete.application.service.ExpiryDateBeforeEventException; import de.fete.application.service.ExpiryDateInPastException; import de.fete.application.service.InvalidTimezoneException; import java.net.URI; @@ -47,6 +48,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { return handleExceptionInternal(ex, problemDetail, headers, status, request); } + /** Handles expiry date before event date. */ + @ExceptionHandler(ExpiryDateBeforeEventException.class) + public ResponseEntity handleExpiryDateBeforeEvent( + ExpiryDateBeforeEventException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, ex.getMessage()); + problemDetail.setTitle("Invalid Expiry Date"); + problemDetail.setType(URI.create("urn:problem-type:expiry-date-before-event")); + return ResponseEntity.badRequest() + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } + /** Handles expiry date validation failures. */ @ExceptionHandler(ExpiryDateInPastException.class) public ResponseEntity handleExpiryDateInPast( diff --git a/backend/src/main/java/de/fete/application/service/EventService.java b/backend/src/main/java/de/fete/application/service/EventService.java index 407b5d3..1f03fa3 100644 --- a/backend/src/main/java/de/fete/application/service/EventService.java +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -32,6 +32,10 @@ public class EventService implements CreateEventUseCase, GetEventUseCase { throw new ExpiryDateInPastException(command.expiryDate()); } + if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) { + throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime()); + } + var event = new Event(); event.setEventToken(EventToken.generate()); event.setOrganizerToken(OrganizerToken.generate()); diff --git a/backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java b/backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java new file mode 100644 index 0000000..ccccef3 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java @@ -0,0 +1,13 @@ +package de.fete.application.service; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +/** Thrown when an event's expiry date is not after the event date. */ +public class ExpiryDateBeforeEventException extends RuntimeException { + + /** Creates a new exception for the given dates. */ + public ExpiryDateBeforeEventException(LocalDate expiryDate, OffsetDateTime dateTime) { + super("Expiry date " + expiryDate + " must be after event date " + dateTime.toLocalDate()); + } +} diff --git a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java index ed1242a..c92e670 100644 --- a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java +++ b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java @@ -56,7 +56,7 @@ class EventControllerIntegrationTest { .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .timezone("Europe/Berlin") .location("Berlin") - .expiryDate(LocalDate.now().plusDays(30)); + .expiryDate(LocalDate.of(2026, 6, 16)); var result = mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) @@ -92,7 +92,7 @@ class EventControllerIntegrationTest { .title("Minimal Event") .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .timezone("UTC") - .expiryDate(LocalDate.now().plusDays(30)); + .expiryDate(LocalDate.of(2026, 6, 16)); var result = mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) @@ -115,10 +115,12 @@ class EventControllerIntegrationTest { @Test void createEventMissingTitleReturns400() throws Exception { + long countBefore = jpaRepository.count(); + var request = new CreateEventRequest() .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .timezone("Europe/Berlin") - .expiryDate(LocalDate.now().plusDays(30)); + .expiryDate(LocalDate.of(2026, 6, 16)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) @@ -127,14 +129,18 @@ class EventControllerIntegrationTest { .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.title").value("Validation Failed")) .andExpect(jsonPath("$.fieldErrors").isArray()); + + assertThat(jpaRepository.count()).isEqualTo(countBefore); } @Test void createEventMissingDateTimeReturns400() throws Exception { + long countBefore = jpaRepository.count(); + var request = new CreateEventRequest() .title("No Date") .timezone("Europe/Berlin") - .expiryDate(LocalDate.now().plusDays(30)); + .expiryDate(LocalDate.of(2026, 6, 16)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) @@ -142,10 +148,14 @@ class EventControllerIntegrationTest { .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.fieldErrors").isArray()); + + assertThat(jpaRepository.count()).isEqualTo(countBefore); } @Test void createEventMissingExpiryDateReturns400() throws Exception { + long countBefore = jpaRepository.count(); + var request = new CreateEventRequest() .title("No Expiry") .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) @@ -157,10 +167,14 @@ class EventControllerIntegrationTest { .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.fieldErrors").isArray()); + + assertThat(jpaRepository.count()).isEqualTo(countBefore); } @Test void createEventExpiryDateInPastReturns400() throws Exception { + long countBefore = jpaRepository.count(); + var request = new CreateEventRequest() .title("Past Expiry") .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) @@ -173,10 +187,14 @@ class EventControllerIntegrationTest { .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); + + assertThat(jpaRepository.count()).isEqualTo(countBefore); } @Test void createEventExpiryDateTodayReturns400() throws Exception { + long countBefore = jpaRepository.count(); + var request = new CreateEventRequest() .title("Today Expiry") .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) @@ -189,6 +207,48 @@ class EventControllerIntegrationTest { .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); + + assertThat(jpaRepository.count()).isEqualTo(countBefore); + } + + @Test + void createEventExpiryDateBeforeEventDateReturns400() throws Exception { + long countBefore = jpaRepository.count(); + + var request = new CreateEventRequest() + .title("Bad Expiry") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .expiryDate(LocalDate.of(2026, 6, 10)); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event")); + + assertThat(jpaRepository.count()).isEqualTo(countBefore); + } + + @Test + void createEventExpiryDateSameAsEventDateReturns400() throws Exception { + long countBefore = jpaRepository.count(); + + var request = new CreateEventRequest() + .title("Same Day Expiry") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .expiryDate(LocalDate.of(2026, 6, 15)); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event")); + + assertThat(jpaRepository.count()).isEqualTo(countBefore); } @Test @@ -197,7 +257,7 @@ class EventControllerIntegrationTest { .title("") .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .timezone("Europe/Berlin") - .expiryDate(LocalDate.now().plusDays(30)); + .expiryDate(LocalDate.of(2026, 6, 16)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) @@ -208,11 +268,13 @@ class EventControllerIntegrationTest { @Test void createEventWithInvalidTimezoneReturns400() throws Exception { + long countBefore = jpaRepository.count(); + var request = new CreateEventRequest() .title("Bad TZ") .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) .timezone("Not/A/Zone") - .expiryDate(LocalDate.now().plusDays(30)); + .expiryDate(LocalDate.of(2026, 6, 16)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) @@ -220,6 +282,8 @@ class EventControllerIntegrationTest { .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.type").value("urn:problem-type:invalid-timezone")); + + assertThat(jpaRepository.count()).isEqualTo(countBefore); } // --- GET /events/{token} tests --- @@ -307,6 +371,7 @@ class EventControllerIntegrationTest { EventJpaEntity event = seedEvent( "RSVP Event", null, "Europe/Berlin", null, LocalDate.now().plusDays(30)); + long countBefore = rsvpJpaRepository.count(); var request = new CreateRsvpRequest().name(""); @@ -315,6 +380,8 @@ class EventControllerIntegrationTest { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")); + + assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore); } @Test @@ -339,6 +406,8 @@ class EventControllerIntegrationTest { @Test void createRsvpForUnknownEventReturns404() throws Exception { + long countBefore = rsvpJpaRepository.count(); + var request = new CreateRsvpRequest().name("Ghost"); mockMvc.perform(post("/api/events/" + UUID.randomUUID() + "/rsvps") @@ -347,6 +416,8 @@ class EventControllerIntegrationTest { .andExpect(status().isNotFound()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found")); + + assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore); } @Test @@ -354,6 +425,7 @@ class EventControllerIntegrationTest { EventJpaEntity event = seedEvent( "Expired Party", null, "Europe/Berlin", null, LocalDate.now().minusDays(1)); + long countBefore = rsvpJpaRepository.count(); var request = new CreateRsvpRequest().name("Late Guest"); @@ -363,6 +435,8 @@ class EventControllerIntegrationTest { .andExpect(status().isConflict()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.type").value("urn:problem-type:event-expired")); + + assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore); } private EventJpaEntity seedEvent( diff --git a/backend/src/test/java/de/fete/application/service/EventServiceTest.java b/backend/src/test/java/de/fete/application/service/EventServiceTest.java index eee8920..c3c3055 100644 --- a/backend/src/test/java/de/fete/application/service/EventServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/EventServiceTest.java @@ -16,7 +16,6 @@ import java.time.Instant; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; -import java.time.ZoneOffset; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -32,6 +31,7 @@ class EventServiceTest { private static final Instant FIXED_INSTANT = LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant(); private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE); + private static final LocalDate TODAY = LocalDate.ofInstant(FIXED_INSTANT, ZONE); @Mock private EventRepository eventRepository; @@ -51,21 +51,21 @@ class EventServiceTest { var command = new CreateEventCommand( "Birthday Party", "Come celebrate!", - OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)), - ZoneId.of("Europe/Berlin"), + TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(), + ZONE, "Berlin", - LocalDate.of(2026, 7, 15) + TODAY.plusDays(120) ); Event result = eventService.createEvent(command); assertThat(result.getTitle()).isEqualTo("Birthday Party"); assertThat(result.getDescription()).isEqualTo("Come celebrate!"); - assertThat(result.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin")); + assertThat(result.getTimezone()).isEqualTo(ZONE); assertThat(result.getLocation()).isEqualTo("Berlin"); assertThat(result.getEventToken()).isNotNull(); assertThat(result.getOrganizerToken()).isNotNull(); - assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK)); + assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE)); } @Test @@ -75,8 +75,8 @@ class EventServiceTest { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, - LocalDate.now(FIXED_CLOCK).plusDays(30) + TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null, + TODAY.plusDays(11) ); eventService.createEvent(command); @@ -90,8 +90,8 @@ class EventServiceTest { void expiryDateTodayThrowsException() { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, - LocalDate.now(FIXED_CLOCK) + TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null, + TODAY ); assertThatThrownBy(() -> eventService.createEvent(command)) @@ -102,8 +102,8 @@ class EventServiceTest { void expiryDateInPastThrowsException() { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, - LocalDate.now(FIXED_CLOCK).minusDays(5) + TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null, + TODAY.minusDays(5) ); assertThatThrownBy(() -> eventService.createEvent(command)) @@ -117,13 +117,56 @@ class EventServiceTest { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, - LocalDate.now(FIXED_CLOCK).plusDays(1) + TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null, + TODAY.plusDays(2) ); Event result = eventService.createEvent(command); - assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6)); + assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(2)); + } + + @Test + void expiryDateSameAsEventDateThrowsException() { + var command = new CreateEventCommand( + "Test", null, + TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), + ZONE, null, + TODAY.plusDays(10) + ); + + assertThatThrownBy(() -> eventService.createEvent(command)) + .isInstanceOf(ExpiryDateBeforeEventException.class); + } + + @Test + void expiryDateBeforeEventDateThrowsException() { + var command = new CreateEventCommand( + "Test", null, + TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), + ZONE, null, + TODAY.plusDays(5) + ); + + assertThatThrownBy(() -> eventService.createEvent(command)) + .isInstanceOf(ExpiryDateBeforeEventException.class); + } + + @Test + void expiryDateDayAfterEventDateSucceeds() { + when(eventRepository.save(any(Event.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var command = new CreateEventCommand( + "Test", null, + TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), + ZONE, null, + TODAY.plusDays(11) + ); + + Event result = eventService.createEvent(command); + + assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(11)); } // --- GetEventUseCase tests (T004) --- @@ -163,9 +206,9 @@ class EventServiceTest { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), + TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZoneId.of("America/New_York"), null, - LocalDate.now(FIXED_CLOCK).plusDays(30) + TODAY.plusDays(11) ); Event result = eventService.createEvent(command);