From 3908c89998237c747ac7c8fc0e81e93a87a34c6f Mon Sep 17 00:00:00 2001 From: nitrix Date: Thu, 12 Mar 2026 18:50:46 +0100 Subject: [PATCH 1/3] Add spec, plan, and tasks for 016-cancel-event feature Co-Authored-By: Claude Opus 4.6 --- .../checklists/requirements.md | 36 ++++ .../contracts/patch-event-endpoint.yaml | 78 ++++++++ specs/016-cancel-event/data-model.md | 82 +++++++++ specs/016-cancel-event/plan.md | 79 +++++++++ specs/016-cancel-event/quickstart.md | 48 +++++ specs/016-cancel-event/research.md | 87 +++++++++ specs/016-cancel-event/spec.md | 97 ++++++++++ specs/016-cancel-event/tasks.md | 167 ++++++++++++++++++ 8 files changed, 674 insertions(+) create mode 100644 specs/016-cancel-event/checklists/requirements.md create mode 100644 specs/016-cancel-event/contracts/patch-event-endpoint.yaml create mode 100644 specs/016-cancel-event/data-model.md create mode 100644 specs/016-cancel-event/plan.md create mode 100644 specs/016-cancel-event/quickstart.md create mode 100644 specs/016-cancel-event/research.md create mode 100644 specs/016-cancel-event/spec.md create mode 100644 specs/016-cancel-event/tasks.md diff --git a/specs/016-cancel-event/checklists/requirements.md b/specs/016-cancel-event/checklists/requirements.md new file mode 100644 index 0000000..7082132 --- /dev/null +++ b/specs/016-cancel-event/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Cancel Event + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-12 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- Feature scope is deliberately tight: cancel + display. No notifications, no undo, no event-list changes. +- Both user stories are P1 because they are two sides of the same coin (cancel action + display result). diff --git a/specs/016-cancel-event/contracts/patch-event-endpoint.yaml b/specs/016-cancel-event/contracts/patch-event-endpoint.yaml new file mode 100644 index 0000000..7fe111b --- /dev/null +++ b/specs/016-cancel-event/contracts/patch-event-endpoint.yaml @@ -0,0 +1,78 @@ +# OpenAPI additions for Cancel Event feature +# To be merged into backend/src/main/resources/openapi/api.yaml + +# PATCH method added to existing /events/{eventToken} path +# Under paths./events/{eventToken}: + +# --- Add PATCH method to existing path --- +# /events/{eventToken}: +# patch: +# operationId: patchEvent +# summary: Update an event (currently: cancel) +# description: | +# Partial update of an event resource. Currently the only supported operation +# is cancellation (setting cancelled to true). Requires the organizer token. +# Cancellation is irreversible. +# tags: [Events] +# parameters: +# - $ref: '#/components/parameters/EventToken' +# requestBody: +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/PatchEventRequest' +# responses: +# '204': +# description: Event updated successfully +# '403': +# description: Invalid organizer token +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/ErrorResponse' +# '404': +# description: Event not found +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/ErrorResponse' +# '409': +# description: Event is already cancelled +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/ErrorResponse' + +# --- New schemas --- + +# PatchEventRequest: +# type: object +# required: [organizerToken, cancelled] +# properties: +# organizerToken: +# type: string +# format: uuid +# description: The organizer token proving ownership of the event +# example: "550e8400-e29b-41d4-a716-446655440001" +# cancelled: +# type: boolean +# description: Set to true to cancel the event (irreversible) +# example: true +# cancellationReason: +# type: string +# maxLength: 2000 +# description: Optional cancellation reason +# example: "Unfortunately the venue is no longer available." + +# --- Extended schema: GetEventResponse --- +# Add to existing GetEventResponse properties: +# cancelled: +# type: boolean +# description: Whether the event has been cancelled +# example: false +# cancellationReason: +# type: string +# nullable: true +# description: Reason for cancellation, if provided +# example: null diff --git a/specs/016-cancel-event/data-model.md b/specs/016-cancel-event/data-model.md new file mode 100644 index 0000000..778096b --- /dev/null +++ b/specs/016-cancel-event/data-model.md @@ -0,0 +1,82 @@ +# Data Model: Cancel Event + +**Feature Branch**: `016-cancel-event` | **Date**: 2026-03-12 + +## Entity Changes + +### Event (extended) + +Two new fields added to the existing Event entity: + +| Field | Type | Constraints | Description | +|--------------------|----------------|------------------------------|--------------------------------------------------| +| cancelled | boolean | NOT NULL, DEFAULT false | Whether the event has been cancelled | +| cancellationReason | String (2000) | Nullable | Optional reason provided by organizer | + +### State Transition + +``` +ACTIVE ──cancel()──► CANCELLED +``` + +- One-way transition only. No path from CANCELLED back to ACTIVE. +- `cancel()` sets `cancelled = true` and optionally sets `cancellationReason`. +- Once cancelled, the event remains visible but RSVP creation is blocked. + +### Validation Rules + +- `cancellationReason` max length: 2000 characters (matches description field). +- `cancellationReason` is plain text only (no HTML/markdown). +- `cancelled` can only transition from `false` to `true`, never back. +- Existing RSVPs are preserved when an event is cancelled (no cascade). + +## Database Migration (Liquibase Changeset 004) + +```xml + + + + + + + + +``` + +## Domain Model Impact + +### Event.java (domain) + +Add fields: +```java +private boolean cancelled; +private String cancellationReason; +``` + +Add method: +```java +public void cancel(String reason) { + if (this.cancelled) { + throw new EventAlreadyCancelledException(); + } + this.cancelled = true; + this.cancellationReason = reason; +} +``` + +### EventJpaEntity.java (persistence) + +Add columns: +```java +@Column(name = "cancelled", nullable = false) +private boolean cancelled; + +@Column(name = "cancellation_reason", length = 2000) +private String cancellationReason; +``` + +## RSVP Impact + +- `POST /events/{eventToken}/rsvps` must check `event.isCancelled()` before accepting. +- If cancelled → return `409 Conflict`. +- Existing RSVPs remain untouched — no delete, no status change. diff --git a/specs/016-cancel-event/plan.md b/specs/016-cancel-event/plan.md new file mode 100644 index 0000000..7901e4f --- /dev/null +++ b/specs/016-cancel-event/plan.md @@ -0,0 +1,79 @@ +# Implementation Plan: Cancel Event + +**Branch**: `016-cancel-event` | **Date**: 2026-03-12 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/016-cancel-event/spec.md` + +## Summary + +Allow organizers to permanently cancel events via a bottom sheet UI. Cancelled events display a red banner to visitors and block new RSVPs. Implementation adds a `PATCH /events/{eventToken}` endpoint, extends the Event entity with `cancelled` and `cancellationReason` fields, and reuses the existing `BottomSheet.vue` component for the cancel interaction. + +## 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 Linux server, mobile-first PWA +**Project Type**: Web application (REST API + SPA) +**Performance Goals**: N/A — simple state transition, no performance-critical path +**Constraints**: Privacy by design (no analytics/tracking), WCAG AA, mobile-first +**Scale/Scope**: Single new endpoint, 2 new DB columns, 1 view extension + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Privacy by Design | PASS | No new data collection beyond organizer-provided reason. No analytics. | +| II. Test-Driven Methodology | PASS | TDD enforced: tests before implementation, E2E mandatory for both user stories. | +| III. API-First Development | PASS | OpenAPI spec updated first, types generated before implementation. `example:` fields included. | +| IV. Simplicity & Quality | PASS | Minimal change: 2 columns, 1 endpoint, reuse existing BottomSheet. No over-engineering. | +| V. Dependency Discipline | PASS | No new dependencies required. | +| VI. Accessibility | PASS | Reuses accessible BottomSheet component. Banner uses semantic HTML + ARIA. | + +**Gate result: PASS** — no violations. + +## Project Structure + +### Documentation (this feature) + +```text +specs/016-cancel-event/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0 output — design decisions +├── data-model.md # Phase 1 output — entity changes +├── quickstart.md # Phase 1 output — implementation overview +├── contracts/ # Phase 1 output — API contract additions +│ └── patch-event-endpoint.yaml +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +```text +backend/ +├── src/main/java/de/fete/ +│ ├── domain/model/Event.java # + cancelled, cancellationReason, cancel() +│ ├── application/service/EventService.java # + CancelEventUseCase implementation +│ ├── adapter/in/web/EventController.java # + cancelEvent endpoint +│ └── adapter/out/persistence/ +│ ├── EventJpaEntity.java # + cancelled, cancellation_reason columns +│ └── EventPersistenceAdapter.java # + mapper updates +├── src/main/resources/ +│ ├── openapi/api.yaml # + cancel endpoint, request/response schemas +│ └── db/changelog/004-add-cancellation-columns.xml # New migration +└── src/test/java/de/fete/ # Unit + integration tests + +frontend/ +├── src/ +│ └── views/EventDetailView.vue # + cancel button, bottom sheet, banner +└── e2e/ # E2E tests for both user stories +``` + +**Structure Decision**: Web application (Option 2) — matches existing project layout with `backend/` and `frontend/` at repository root. + +## Complexity Tracking + +> No violations — table not applicable. diff --git a/specs/016-cancel-event/quickstart.md b/specs/016-cancel-event/quickstart.md new file mode 100644 index 0000000..69ca01a --- /dev/null +++ b/specs/016-cancel-event/quickstart.md @@ -0,0 +1,48 @@ +# Quickstart: Cancel Event + +**Feature Branch**: `016-cancel-event` + +## What This Feature Does + +Adds the ability for an organizer to permanently cancel an event. Cancelled events display a red banner to visitors and block new RSVPs. + +## Implementation Scope + +### Backend +1. **Liquibase migration** (003): Add `cancelled` (boolean) and `cancellation_reason` (varchar 2000) columns to `events` table. +2. **Domain model**: Extend `Event.java` with `cancelled` and `cancellationReason` fields + `cancel()` method. +3. **JPA entity**: Extend `EventJpaEntity.java` with matching columns and mapper updates. +4. **OpenAPI spec**: Add `PATCH /events/{eventToken}` endpoint + extend `GetEventResponse` with cancellation fields. +5. **Use case**: New `CancelEventUseCase` interface + implementation in `EventService`. +6. **Controller**: Implement `cancelEvent` in `EventController`. +7. **RSVP guard**: Add cancelled check to RSVP creation (return 409). + +### Frontend +1. **Cancel bottom sheet**: Add cancel button (organizer-only) + bottom sheet with textarea and confirm button in `EventDetailView.vue`. +2. **Cancellation banner**: Red banner at top of event detail when `cancelled === true`. +3. **RSVP hiding**: Hide `RsvpBar` when event is cancelled. +4. **API client**: Use generated types from updated OpenAPI spec. + +### Testing +1. **Backend unit tests**: Cancel use case, RSVP rejection on cancelled events. +2. **Backend integration tests**: Full cancel flow via API. +3. **Frontend unit tests**: Cancel bottom sheet, banner display, RSVP hiding. +4. **E2E tests**: Organizer cancels event, attendee sees cancelled event. + +## Key Files to Modify + +| File | Change | +|------|--------| +| `backend/src/main/resources/openapi/api.yaml` | New endpoint + schema extensions | +| `backend/src/main/resources/db/changelog/` | New changeset 003 | +| `backend/src/main/java/de/fete/domain/model/Event.java` | Add cancelled fields + cancel() | +| `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java` | Add columns | +| `backend/src/main/java/de/fete/application/service/EventService.java` | Implement cancel | +| `backend/src/main/java/de/fete/adapter/in/web/EventController.java` | Implement endpoint | +| `frontend/src/views/EventDetailView.vue` | Cancel button, bottom sheet, banner | + +## Prerequisites + +- Existing RSVP bottom sheet pattern (already implemented) +- Organizer token stored in localStorage (already implemented) +- `BottomSheet.vue` component (already exists) diff --git a/specs/016-cancel-event/research.md b/specs/016-cancel-event/research.md new file mode 100644 index 0000000..169fe54 --- /dev/null +++ b/specs/016-cancel-event/research.md @@ -0,0 +1,87 @@ +# Research: Cancel Event + +**Feature Branch**: `016-cancel-event` | **Date**: 2026-03-12 + +## Decision 1: API Endpoint Design + +**Decision**: Use `PATCH /events/{eventToken}` with organizer token and cancellation fields in request body. + +**Rationale**: +- PATCH is standard REST for partial resource updates — cancellation is a state change on the event resource. +- The event is not removed, so DELETE is not appropriate. The event remains visible with a cancellation banner. +- The organizer token is sent in the request body to keep it out of URL/query strings and server access logs. +- Request body: `{ "organizerToken": "uuid", "cancelled": true, "cancellationReason": "optional string" }`. +- Response: `204 No Content` on success. +- Error responses: `404` if event not found, `403` if organizer token is wrong, `409` if already cancelled. +- Currently the only supported PATCH operation is cancellation. The endpoint validates that `cancelled` is `true` and rejects requests that attempt to set other fields. + +**Alternatives considered**: +- `POST /events/{eventToken}/cancel` — rejected because a dedicated sub-resource endpoint is RPC-style, not RESTful. PATCH on the resource itself is the standard approach. +- `DELETE /events/{eventToken}` — rejected because the event is not deleted, it remains visible with a cancellation banner. + +## Decision 2: Database Schema Extension + +**Decision**: Add two columns to the `events` table: `cancelled BOOLEAN NOT NULL DEFAULT FALSE` and `cancellation_reason VARCHAR(2000)`. + +**Rationale**: +- Boolean flag is the simplest representation of the cancelled state. +- 2000 chars matches the existing description field limit — consistent and generous. +- DEFAULT FALSE ensures backward compatibility with existing rows. +- A Liquibase changeset (003) adds both columns. + +**Alternatives considered**: +- Enum status field (`ACTIVE`, `CANCELLED`) — rejected as over-engineering for a binary state with no other planned transitions. +- Separate cancellation table — rejected as unnecessary complexity for two columns. + +## Decision 3: RSVP Blocking on Cancelled Events + +**Decision**: The RSVP creation endpoint (`POST /events/{eventToken}/rsvps`) checks the event's cancelled flag and returns `409 Conflict` if the event is cancelled. + +**Rationale**: +- Server-side enforcement is required (FR-006) — frontend hiding the button is not sufficient. +- 409 Conflict is semantically correct: the request conflicts with the current state of the resource. +- Existing RSVPs are preserved (FR-007) — no cascade or cleanup needed. + +**Alternatives considered**: +- 400 Bad Request — rejected because the request itself is well-formed; the conflict is with resource state. +- 422 Unprocessable Entity — rejected because the issue is not validation but state conflict. + +## Decision 4: Frontend Cancel Bottom Sheet + +**Decision**: Reuse the existing `BottomSheet.vue` component. Add cancel-specific content (textarea + confirm button) directly in `EventDetailView.vue`, similar to how the RSVP form is embedded. + +**Rationale**: +- The spec explicitly requires the bottom sheet pattern consistent with RSVP flow (FR-002). +- `BottomSheet.vue` is already a generic, accessible, glassmorphism-styled container. +- No need for a separate component — the cancel form is simple (textarea + button + error message). +- Error handling follows the same pattern as RSVP: inline error in the sheet, button re-enabled. + +**Alternatives considered**: +- Separate `CancelBottomSheet.vue` component — rejected as unnecessary extraction for a simple form. +- ConfirmDialog instead of BottomSheet — rejected because spec explicitly requires bottom sheet. + +## Decision 5: Organizer Token Authorization + +**Decision**: The cancel endpoint receives the organizer token in the request body. The frontend retrieves it from localStorage via `useEventStorage.getOrganizerToken()`. + +**Rationale**: +- Consistent with how organizer identity works throughout the app — token-based, no auth system. +- The organizer token is already stored in localStorage when the event is created. +- Body parameter keeps the token out of URL/query strings and server access logs. + +**Alternatives considered**: +- Authorization header — rejected because there's no auth system; the organizer token is not a session token. +- Query parameter — rejected to keep token out of server logs (same reason the attendee endpoint should eventually be migrated away from query params). + +## Decision 6: GetEventResponse Extension + +**Decision**: Add `cancelled: boolean` and `cancellationReason: string | null` to the `GetEventResponse` schema. + +**Rationale**: +- The frontend needs to know whether an event is cancelled to show the banner and hide RSVP buttons. +- Both fields are always returned (no separate endpoint needed). +- `cancelled` defaults to `false` for existing events. + +**Alternatives considered**: +- Separate endpoint for cancellation status — rejected as unnecessary network overhead. +- Only return cancellation info for cancelled events — rejected because the frontend needs the boolean regardless to decide UI state. diff --git a/specs/016-cancel-event/spec.md b/specs/016-cancel-event/spec.md new file mode 100644 index 0000000..9c78487 --- /dev/null +++ b/specs/016-cancel-event/spec.md @@ -0,0 +1,97 @@ +# Feature Specification: Cancel Event + +**Feature Branch**: `016-cancel-event` +**Created**: 2026-03-12 +**Status**: Draft +**Input**: User description: "As an organizer, I want to cancel an event so that attendees know the event will not take place" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Organizer Cancels an Event (Priority: P1) + +An organizer navigates to their event page (identified by having the organizer token). They decide to cancel the event. They tap a "Cancel Event" button, which opens a bottom sheet (visually similar to the attend/RSVP flow). The bottom sheet contains a text area for an optional cancellation reason and a confirm button. After confirming, the event is permanently marked as cancelled. This action is irreversible. + +**Why this priority**: This is the core action of the feature. Without it, nothing else matters. + +**Independent Test**: Can be fully tested by viewing the event with the organizer token, tapping cancel, optionally entering a reason, and confirming. The event's cancelled state persists on reload. + +**Acceptance Scenarios**: + +1. **Given** an active event viewed by someone who has the organizer token, **When** the organizer taps "Cancel Event", **Then** a bottom sheet opens with a text area and a confirm button. +2. **Given** the cancel bottom sheet is open, **When** the organizer enters a cancellation reason and taps the confirm button, **Then** the event is marked as cancelled with the provided reason. +3. **Given** the cancel bottom sheet is open, **When** the organizer taps confirm without entering a reason, **Then** the event is marked as cancelled without a reason. +4. **Given** a cancelled event, **When** the organizer revisits the event page, **Then** the event remains cancelled (irreversible). +5. **Given** the cancel bottom sheet is open, **When** the organizer taps confirm and the API call fails, **Then** an error message is displayed in the bottom sheet, the sheet remains open, and the confirm button is re-enabled for retry. + +--- + +### User Story 2 - Attendee Sees Cancelled Event (Priority: P1) + +An attendee (or any visitor) opens the event detail page for a cancelled event. A prominent red banner is displayed at the top of the page, clearly communicating that the event has been cancelled. If the organizer provided a cancellation reason, it is shown within the banner. The RSVP buttons are hidden — no new RSVPs can be submitted. + +**Why this priority**: Equal to P1 because the cancellation must be visible to attendees for the feature to deliver value. Without this, cancelling has no effect from the attendee's perspective. + +**Independent Test**: Can be tested by viewing a cancelled event's detail page and verifying the banner appears, the reason is displayed (if provided), and RSVP buttons are hidden. + +**Acceptance Scenarios**: + +1. **Given** a cancelled event with a cancellation reason, **When** a visitor opens the event detail page, **Then** a prominent red banner is displayed showing that the event is cancelled along with the reason. +2. **Given** a cancelled event without a cancellation reason, **When** a visitor opens the event detail page, **Then** a prominent red banner is displayed showing that the event is cancelled, without a reason text. +3. **Given** a cancelled event, **When** a visitor opens the event detail page, **Then** the RSVP buttons are not visible. +4. **Given** a cancelled event, **When** a visitor opens the event detail page, **Then** all other event details (title, date, location, description) remain visible. + +--- + +### Edge Cases + +- What happens when the organizer tries to cancel an already cancelled event? The cancel button is not available on an already cancelled event. +- What happens to existing RSVPs when an event is cancelled? They are preserved as-is but no new RSVPs can be submitted. +- What happens when the event is both cancelled and expired? The auto-delete mechanism (feature 013) continues to apply normally — cancelled events are deleted on the same schedule as non-cancelled events. +- What happens when the cancellation API call fails (network error, server error)? The bottom sheet remains open, a visible error message is displayed within the sheet, and the confirm button is re-enabled so the organizer can retry. +- How are cancelled events displayed in the event list? Out of scope for this feature — the event list view is not affected. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST allow the organizer to cancel an event via the organizer view. +- **FR-002**: The cancellation interaction MUST use a bottom sheet pattern consistent with the existing RSVP/attend flow. +- **FR-003**: The bottom sheet MUST contain a text area for an optional cancellation reason and a confirm button. +- **FR-004**: Cancellation MUST be irreversible — once cancelled, there is no way to undo it. +- **FR-005**: System MUST store a cancelled flag and an optional cancellation reason for the event. +- **FR-006**: System MUST NOT allow new RSVPs for a cancelled event. +- **FR-007**: System MUST preserve existing RSVPs when an event is cancelled. +- **FR-008**: The event detail page MUST display a prominent red banner for cancelled events. +- **FR-009**: The banner MUST include the cancellation reason when one was provided. +- **FR-010**: The RSVP buttons MUST be hidden on a cancelled event's detail page. +- **FR-011**: All other event information MUST remain visible on a cancelled event's detail page. +- **FR-012**: The cancel button MUST NOT be shown on an already cancelled event. +- **FR-013**: There MUST be no push notifications, emails, or any active notification mechanism for cancellations. +- **FR-014**: If the cancellation API call fails, the bottom sheet MUST remain open, display an error message, and allow the organizer to retry. +- **FR-015**: Changes to the event list view for cancelled events are explicitly OUT OF SCOPE for this feature. + +### Key Entities + +- **Event** (extended): Gains a cancelled state (boolean) and an optional cancellation reason (free text). An event can transition from active to cancelled, but not back. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Organizer can cancel an event in under 30 seconds (open bottom sheet, optionally type reason, confirm). +- **SC-002**: 100% of visitors to a cancelled event's detail page see the cancellation banner without scrolling. +- **SC-003**: 0% of cancelled events accept new RSVPs. +- **SC-004**: Existing RSVPs are fully preserved after cancellation (no data loss). + +## Clarifications + +### Session 2026-03-12 + +- Q: How should cancelled events appear in the event list view? → A: Out of scope for this feature — event list view is not affected. +- Q: What should happen when the cancellation API call fails? → A: Error message displayed in the bottom sheet, sheet remains open, confirm button re-enabled for retry. + +## Assumptions + +- The bottom sheet component pattern already exists from the RSVP/attend flow and can be reused. +- The cancellation reason has a maximum length of 2000 characters (consistent with the event description field). +- The cancellation reason is plain text (no formatting or markup). diff --git a/specs/016-cancel-event/tasks.md b/specs/016-cancel-event/tasks.md new file mode 100644 index 0000000..9127f28 --- /dev/null +++ b/specs/016-cancel-event/tasks.md @@ -0,0 +1,167 @@ +# Tasks: Cancel Event + +**Input**: Design documents from `/specs/016-cancel-event/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/patch-event-endpoint.yaml + +**Tests**: Included — constitution mandates TDD (tests before implementation) and E2E for every frontend user story. + +**Organization**: Tasks grouped by user story. Both stories are P1 but US2 depends on US1's backend work. + +## 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) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: OpenAPI spec, database migration, and domain model changes that both user stories depend on. + +- [ ] T001 Update OpenAPI spec with PATCH endpoint on `/events/{eventToken}`, PatchEventRequest schema (`organizerToken`, `cancelled`, `cancellationReason`), and extend GetEventResponse with `cancelled`/`cancellationReason` fields in `backend/src/main/resources/openapi/api.yaml` +- [ ] T002 Add Liquibase changeset 004 adding `cancelled` (BOOLEAN NOT NULL DEFAULT FALSE) and `cancellation_reason` (VARCHAR 2000) columns to `events` table in `backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml` and register it in `db.changelog-master.xml` +- [ ] T003 [P] Extend domain model `Event.java` with `cancelled`, `cancellationReason` fields and `cancel(String reason)` method (throws `EventAlreadyCancelledException`). Create `EventAlreadyCancelledException` in `backend/src/main/java/de/fete/application/service/` following the pattern of existing exceptions. Domain model: `backend/src/main/java/de/fete/domain/model/Event.java` +- [ ] T004 [P] Extend JPA entity `EventJpaEntity.java` with `cancelled` and `cancellation_reason` columns and update mapper in `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java` +- [ ] T005 Regenerate frontend TypeScript types from updated OpenAPI spec via `cd frontend && npm run generate-types` (or equivalent openapi-typescript command) + +**Checkpoint**: Schema, migration, and domain model ready. Both user stories can now proceed. + +--- + +## Phase 2: User Story 1 — Organizer Cancels an Event (Priority: P1) MVP + +**Goal**: Organizer can cancel an event via a bottom sheet with optional reason. Cancellation is irreversible and persists on reload. + +**Independent Test**: View event with organizer token, tap cancel, optionally enter reason, confirm. Event remains cancelled on reload. + +### Tests for User Story 1 + +> **Write these tests FIRST — ensure they FAIL before implementation** + +- [ ] T006 [P] [US1] Write unit test for `cancel()` domain method (happy path, already-cancelled throws) in `backend/src/test/java/de/fete/domain/model/EventTest.java` +- [ ] T007 [P] [US1] Write unit test for cancel use case in EventService (delegates to domain, saves, 403/404/409 cases) in `backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java` +- [ ] T008 [P] [US1] Write integration test for `PATCH /events/{eventToken}` endpoint (204 success, 403 wrong token, 404 not found, 409 already cancelled) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerCancelTest.java` +- [ ] T009 [P] [US1] Write E2E test: organizer opens cancel bottom sheet, enters reason, confirms — event shows as cancelled on reload in `frontend/e2e/cancel-event.spec.ts` +- [ ] T010 [P] [US1] Write E2E test: organizer cancels without reason — event shows as cancelled in `frontend/e2e/cancel-event.spec.ts` +- [ ] T011 [P] [US1] Write E2E test: cancel API fails — error displayed in bottom sheet, button re-enabled for retry in `frontend/e2e/cancel-event.spec.ts` + +### Implementation for User Story 1 + +- [ ] T012 [US1] Create `CancelEventUseCase` interface (or add method to existing use case interface) in `backend/src/main/java/de/fete/domain/port/in/` +- [ ] T013 [US1] Implement cancel logic in `EventService.java` — load event, verify organizer token, call `event.cancel(reason)`, persist in `backend/src/main/java/de/fete/application/service/EventService.java` +- [ ] T014 [US1] Implement `cancelEvent` endpoint in `EventController.java` — PATCH handler, request body binding, error mapping (403/404/409) in `backend/src/main/java/de/fete/adapter/in/web/EventController.java` +- [ ] T015 [US1] Add cancel button (visible only when organizer token exists and event not cancelled — covers FR-012) and cancel bottom sheet (textarea with 2000 char limit + confirm button + inline error) to `frontend/src/views/EventDetailView.vue` +- [ ] T016 [US1] Wire cancel bottom sheet confirm action to `PATCH /events/{eventToken}` API call via openapi-fetch, handle success (reload event data) and error (show inline message, re-enable button) in `frontend/src/views/EventDetailView.vue` + +**Checkpoint**: Organizer can cancel an event. All US1 acceptance scenarios pass. + +--- + +## Phase 3: User Story 2 — Attendee Sees Cancelled Event (Priority: P1) + +**Goal**: Visitors see a prominent red cancellation banner on cancelled events. RSVP buttons are hidden. All other event details remain visible. + +**Independent Test**: View a cancelled event's detail page — banner visible (with reason if provided), RSVP buttons hidden, other details intact. + +### Tests for User Story 2 + +> **Write these tests FIRST — ensure they FAIL before implementation** + +- [ ] T017 [P] [US2] Write unit test for RSVP creation rejection on cancelled events (409 Conflict) in `backend/src/test/java/de/fete/application/service/RsvpServiceCancelledTest.java` +- [ ] T018 [P] [US2] Write integration test for `POST /events/{eventToken}/rsvps` returning 409 when event is cancelled in `backend/src/test/java/de/fete/adapter/in/web/RsvpControllerCancelledTest.java` +- [ ] T019 [P] [US2] Write E2E test: visitor sees red banner with cancellation reason on cancelled event in `frontend/e2e/cancelled-event-visitor.spec.ts` +- [ ] T020 [P] [US2] Write E2E test: visitor sees red banner without reason when no reason was provided in `frontend/e2e/cancelled-event-visitor.spec.ts` +- [ ] T021 [P] [US2] Write E2E test: RSVP buttons hidden on cancelled event, other details remain visible in `frontend/e2e/cancelled-event-visitor.spec.ts` + +### Implementation for User Story 2 + +- [ ] T022 [US2] Add cancelled-event guard to RSVP creation — check `event.isCancelled()`, return 409 Conflict in `backend/src/main/java/de/fete/application/service/RsvpService.java` +- [ ] T023 [US2] Add cancellation banner component/section (red, prominent, includes reason if present, WCAG AA contrast) to `frontend/src/views/EventDetailView.vue` +- [ ] T024 [US2] Hide RSVP buttons (`RsvpBar` or equivalent) when `event.cancelled === true` in `frontend/src/views/EventDetailView.vue` +- [ ] ~~T025~~ Merged into T015 (cancel button v-if already handles FR-012) + +**Checkpoint**: Both user stories fully functional. All acceptance scenarios pass. + +--- + +## Phase 4: Polish & Cross-Cutting Concerns + +**Purpose**: Validation, edge cases, and final cleanup. + +- [ ] T026 Verify cancellationReason max length (2000 chars) is enforced at API level (OpenAPI `maxLength`), domain level in `Event.java`, and UI level (textarea maxlength/counter) +- [ ] T027 Run full backend test suite (`cd backend && ./mvnw verify`) and fix any failures +- [ ] T028 Run full frontend test suite (`cd frontend && npm run test:unit`) and fix any failures +- [ ] T029 Run E2E tests (`cd frontend && npx playwright test`) and fix any failures +- [ ] T030 Run backend checkstyle (`cd backend && ./mvnw checkstyle:check`) and fix violations + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **US1 (Phase 2)**: Depends on Setup completion +- **US2 (Phase 3)**: Depends on Setup completion; backend RSVP guard is independent of US1, but frontend banner/hiding can be built in parallel with US1 +- **Polish (Phase 4)**: Depends on all user stories complete + +### User Story Dependencies + +- **US1 (P1)**: Can start after Phase 1 (Setup). No dependency on US2. +- **US2 (P1)**: Can start after Phase 1 (Setup). Backend RSVP guard is independent of US1. Frontend banner/hiding only needs the `cancelled`/`cancellationReason` fields in GetEventResponse (from Setup). + +### Within Each User Story + +- Tests MUST be written and FAIL before implementation +- Domain model before service layer +- Service layer before controller/endpoint +- Backend before frontend (API must exist before UI wires to it) +- Core implementation before error handling polish + +### Parallel Opportunities + +- T003 + T004 (domain model + JPA entity) can run in parallel +- All US1 test tasks (T006–T011) can run in parallel +- All US2 test tasks (T017–T021) can run in parallel +- US1 backend (T012–T014) and US2 backend (T022) can run in parallel after Setup +- US1 frontend (T015–T016) and US2 frontend (T023–T025) can run in parallel if backend is ready + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all US1 tests together (TDD - write first, expect failures): +Task: T006 "Unit test for cancel() domain method" +Task: T007 "Unit test for cancel use case in EventService" +Task: T008 "Integration test for PATCH /events/{eventToken}" +Task: T009 "E2E test: organizer cancels with reason" +Task: T010 "E2E test: organizer cancels without reason" +Task: T011 "E2E test: cancel API failure handling" + +# Then implement sequentially: +Task: T012 "CancelEventUseCase interface" +Task: T013 "EventService cancel implementation" +Task: T014 "EventController cancelEvent endpoint" +Task: T015 "Cancel button + bottom sheet in EventDetailView" +Task: T016 "Wire cancel action to API" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (OpenAPI, migration, domain model) +2. Complete Phase 2: US1 — Organizer cancels event +3. **STOP and VALIDATE**: All US1 acceptance scenarios pass +4. Deploy/demo if ready — cancellation works end-to-end + +### Incremental Delivery + +1. Setup → Shared infrastructure ready +2. US1 → Organizer can cancel → Test → Deploy (MVP!) +3. US2 → Attendees see cancellation + RSVP blocked → Test → Deploy +4. Polish → Full verification, checkstyle, edge cases From 541017965f2111ff67e673440228719651639cde Mon Sep 17 00:00:00 2001 From: nitrix Date: Thu, 12 Mar 2026 19:52:22 +0100 Subject: [PATCH 2/3] Implement cancel-event feature (016) Add PATCH /events/{eventToken} endpoint for organizers to cancel events, cancellation banner for visitors, and RSVP rejection on cancelled events. Co-Authored-By: Claude Opus 4.6 --- .../fete/adapter/in/web/EventController.java | 20 +- .../in/web/GlobalExceptionHandler.java | 28 +++ .../out/persistence/EventJpaEntity.java | 26 +++ .../persistence/EventPersistenceAdapter.java | 4 + .../EventAlreadyCancelledException.java | 12 ++ .../service/EventCancelledException.java | 12 ++ .../application/service/EventService.java | 29 ++- .../fete/application/service/RsvpService.java | 4 + .../main/java/de/fete/domain/model/Event.java | 22 ++ .../domain/port/in/UpdateEventUseCase.java | 13 ++ .../004-add-cancellation-columns.xml | 17 ++ .../db/changelog/db.changelog-master.xml | 1 + backend/src/main/resources/openapi/api.yaml | 78 ++++++++ .../web/EventControllerIntegrationTest.java | 143 +++++++++++++ .../service/EventServiceCancelTest.java | 138 +++++++++++++ frontend/e2e/cancel-event.spec.ts | 162 +++++++++++++++ frontend/e2e/cancelled-event-visitor.spec.ts | 74 +++++++ frontend/src/assets/main.css | 8 + frontend/src/views/EventDetailView.vue | 188 +++++++++++++++++- specs/016-cancel-event/tasks.md | 131 +++--------- 20 files changed, 1004 insertions(+), 106 deletions(-) create mode 100644 backend/src/main/java/de/fete/application/service/EventAlreadyCancelledException.java create mode 100644 backend/src/main/java/de/fete/application/service/EventCancelledException.java create mode 100644 backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java create mode 100644 backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml create mode 100644 backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java create mode 100644 frontend/e2e/cancel-event.spec.ts create mode 100644 frontend/e2e/cancelled-event-visitor.spec.ts 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 307e47c..8a94dcc 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 @@ -8,6 +8,7 @@ import de.fete.adapter.in.web.model.CreateRsvpRequest; import de.fete.adapter.in.web.model.CreateRsvpResponse; import de.fete.adapter.in.web.model.GetAttendeesResponse; import de.fete.adapter.in.web.model.GetEventResponse; +import de.fete.adapter.in.web.model.PatchEventRequest; import de.fete.application.service.EventNotFoundException; import de.fete.application.service.InvalidTimezoneException; import de.fete.domain.model.CreateEventCommand; @@ -22,6 +23,7 @@ import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.GetAttendeesUseCase; import de.fete.domain.port.in.GetEventUseCase; +import de.fete.domain.port.in.UpdateEventUseCase; import java.time.DateTimeException; import java.time.ZoneId; import java.util.List; @@ -40,6 +42,7 @@ public class EventController implements EventsApi { private final CancelRsvpUseCase cancelRsvpUseCase; private final CountAttendeesByEventUseCase countAttendeesByEventUseCase; private final GetAttendeesUseCase getAttendeesUseCase; + private final UpdateEventUseCase updateEventUseCase; /** Creates a new controller with the given use cases. */ public EventController( @@ -48,13 +51,15 @@ public class EventController implements EventsApi { CreateRsvpUseCase createRsvpUseCase, CancelRsvpUseCase cancelRsvpUseCase, CountAttendeesByEventUseCase countAttendeesByEventUseCase, - GetAttendeesUseCase getAttendeesUseCase) { + GetAttendeesUseCase getAttendeesUseCase, + UpdateEventUseCase updateEventUseCase) { this.createEventUseCase = createEventUseCase; this.getEventUseCase = getEventUseCase; this.createRsvpUseCase = createRsvpUseCase; this.cancelRsvpUseCase = cancelRsvpUseCase; this.countAttendeesByEventUseCase = countAttendeesByEventUseCase; this.getAttendeesUseCase = getAttendeesUseCase; + this.updateEventUseCase = updateEventUseCase; } @Override @@ -97,10 +102,23 @@ public class EventController implements EventsApi { response.setLocation(event.getLocation()); response.setAttendeeCount( (int) countAttendeesByEventUseCase.countByEvent(evtToken)); + response.setCancelled(event.isCancelled()); + response.setCancellationReason(event.getCancellationReason()); return ResponseEntity.ok(response); } + @Override + public ResponseEntity patchEvent( + UUID eventToken, UUID organizerToken, PatchEventRequest request) { + updateEventUseCase.cancelEvent( + new EventToken(eventToken), + new OrganizerToken(organizerToken), + request.getCancelled(), + request.getCancellationReason()); + return ResponseEntity.noContent().build(); + } + @Override public ResponseEntity getAttendees( UUID eventToken, UUID organizerToken) { 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 30bcd2d..bd16dcd 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,7 @@ package de.fete.adapter.in.web; +import de.fete.application.service.EventAlreadyCancelledException; +import de.fete.application.service.EventCancelledException; import de.fete.application.service.EventExpiredException; import de.fete.application.service.EventNotFoundException; import de.fete.application.service.ExpiryDateBeforeEventException; @@ -75,6 +77,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { .body(problemDetail); } + /** Handles attempt to cancel an already cancelled event. */ + @ExceptionHandler(EventAlreadyCancelledException.class) + public ResponseEntity handleEventAlreadyCancelled( + EventAlreadyCancelledException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, ex.getMessage()); + problemDetail.setTitle("Event Already Cancelled"); + problemDetail.setType(URI.create("urn:problem-type:event-already-cancelled")); + return ResponseEntity.status(HttpStatus.CONFLICT) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } + + /** Handles RSVP on cancelled event. */ + @ExceptionHandler(EventCancelledException.class) + public ResponseEntity handleEventCancelled( + EventCancelledException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, ex.getMessage()); + problemDetail.setTitle("Event Cancelled"); + problemDetail.setType(URI.create("urn:problem-type:event-cancelled")); + return ResponseEntity.status(HttpStatus.CONFLICT) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } + /** Handles RSVP on expired event. */ @ExceptionHandler(EventExpiredException.class) public ResponseEntity handleEventExpired( diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java b/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java index 04a33b0..48ef235 100644 --- a/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java @@ -46,6 +46,12 @@ public class EventJpaEntity { @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; + @Column(name = "cancelled", nullable = false) + private boolean cancelled; + + @Column(name = "cancellation_reason", length = 2000) + private String cancellationReason; + /** Returns the internal database ID. */ public Long getId() { return id; @@ -145,4 +151,24 @@ public class EventJpaEntity { public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + /** Returns whether the event is cancelled. */ + public boolean isCancelled() { + return cancelled; + } + + /** Sets the cancelled flag. */ + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + /** Returns the cancellation reason. */ + public String getCancellationReason() { + return cancellationReason; + } + + /** Sets the cancellation reason. */ + public void setCancellationReason(String cancellationReason) { + this.cancellationReason = cancellationReason; + } } 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 be51d9c..358f10f 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 @@ -48,6 +48,8 @@ public class EventPersistenceAdapter implements EventRepository { entity.setLocation(event.getLocation()); entity.setExpiryDate(event.getExpiryDate()); entity.setCreatedAt(event.getCreatedAt()); + entity.setCancelled(event.isCancelled()); + entity.setCancellationReason(event.getCancellationReason()); return entity; } @@ -63,6 +65,8 @@ public class EventPersistenceAdapter implements EventRepository { event.setLocation(entity.getLocation()); event.setExpiryDate(entity.getExpiryDate()); event.setCreatedAt(entity.getCreatedAt()); + event.setCancelled(entity.isCancelled()); + event.setCancellationReason(entity.getCancellationReason()); return event; } } diff --git a/backend/src/main/java/de/fete/application/service/EventAlreadyCancelledException.java b/backend/src/main/java/de/fete/application/service/EventAlreadyCancelledException.java new file mode 100644 index 0000000..72f1ec4 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/EventAlreadyCancelledException.java @@ -0,0 +1,12 @@ +package de.fete.application.service; + +import java.util.UUID; + +/** Thrown when attempting to cancel an event that is already cancelled. */ +public class EventAlreadyCancelledException extends RuntimeException { + + /** Creates a new exception for the given event token. */ + public EventAlreadyCancelledException(UUID eventToken) { + super("Event is already cancelled: " + eventToken); + } +} diff --git a/backend/src/main/java/de/fete/application/service/EventCancelledException.java b/backend/src/main/java/de/fete/application/service/EventCancelledException.java new file mode 100644 index 0000000..14dbe68 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/EventCancelledException.java @@ -0,0 +1,12 @@ +package de.fete.application.service; + +import java.util.UUID; + +/** Thrown when an RSVP is attempted on a cancelled event. */ +public class EventCancelledException extends RuntimeException { + + /** Creates a new exception for the given event token. */ + public EventCancelledException(UUID eventToken) { + super("Event is cancelled: " + 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 70d226d..f8fbcec 100644 --- a/backend/src/main/java/de/fete/application/service/EventService.java +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -6,16 +6,18 @@ 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.in.UpdateEventUseCase; import de.fete.domain.port.out.EventRepository; import java.time.Clock; import java.time.LocalDate; import java.time.OffsetDateTime; import java.util.Optional; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /** Application service implementing event creation and retrieval. */ @Service -public class EventService implements CreateEventUseCase, GetEventUseCase { +public class EventService implements CreateEventUseCase, GetEventUseCase, UpdateEventUseCase { private static final int EXPIRY_DAYS_AFTER_EVENT = 7; @@ -50,4 +52,29 @@ public class EventService implements CreateEventUseCase, GetEventUseCase { public Optional getByEventToken(EventToken eventToken) { return eventRepository.findByEventToken(eventToken); } + + @Transactional + @Override + public void cancelEvent( + EventToken eventToken, OrganizerToken organizerToken, + Boolean cancelled, String reason) { + if (!Boolean.TRUE.equals(cancelled)) { + return; + } + + Event event = eventRepository.findByEventToken(eventToken) + .orElseThrow(() -> new EventNotFoundException(eventToken.value())); + + if (!event.getOrganizerToken().equals(organizerToken)) { + throw new InvalidOrganizerTokenException(); + } + + if (event.isCancelled()) { + throw new EventAlreadyCancelledException(eventToken.value()); + } + + event.setCancelled(true); + event.setCancellationReason(reason); + eventRepository.save(event); + } } 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 56632dd..4e9e189 100644 --- a/backend/src/main/java/de/fete/application/service/RsvpService.java +++ b/backend/src/main/java/de/fete/application/service/RsvpService.java @@ -42,6 +42,10 @@ public class RsvpService Event event = eventRepository.findByEventToken(eventToken) .orElseThrow(() -> new EventNotFoundException(eventToken.value())); + if (event.isCancelled()) { + throw new EventCancelledException(eventToken.value()); + } + if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) { throw new EventExpiredException(eventToken.value()); } 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 27d2cf6..3e84a92 100644 --- a/backend/src/main/java/de/fete/domain/model/Event.java +++ b/backend/src/main/java/de/fete/domain/model/Event.java @@ -17,6 +17,8 @@ public class Event { private String location; private LocalDate expiryDate; private OffsetDateTime createdAt; + private boolean cancelled; + private String cancellationReason; /** Returns the internal database ID. */ public Long getId() { @@ -117,4 +119,24 @@ public class Event { public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + /** Returns whether the event has been cancelled. */ + public boolean isCancelled() { + return cancelled; + } + + /** Sets the cancelled flag. */ + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + /** Returns the cancellation reason, if any. */ + public String getCancellationReason() { + return cancellationReason; + } + + /** Sets the cancellation reason. */ + public void setCancellationReason(String cancellationReason) { + this.cancellationReason = cancellationReason; + } } diff --git a/backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java b/backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java new file mode 100644 index 0000000..6038dfa --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java @@ -0,0 +1,13 @@ +package de.fete.domain.port.in; + +import de.fete.domain.model.EventToken; +import de.fete.domain.model.OrganizerToken; + +/** Inbound port for updating an event. */ +public interface UpdateEventUseCase { + + /** Cancels the event identified by the given token. */ + void cancelEvent( + EventToken eventToken, OrganizerToken organizerToken, + Boolean cancelled, String reason); +} diff --git a/backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml b/backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml new file mode 100644 index 0000000..85e788f --- /dev/null +++ b/backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + 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 069351a..9c5e35b 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -9,5 +9,6 @@ + diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 190df2e..72a356b 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -184,6 +184,58 @@ paths: schema: $ref: "#/components/schemas/ProblemDetail" + patch: + operationId: patchEvent + summary: Update an event (currently cancel) + description: | + Partial update of an event resource. Currently the only supported operation + is cancellation (setting cancelled to true). Requires the organizer token. + Cancellation is irreversible. + tags: + - events + parameters: + - name: eventToken + in: path + required: true + schema: + type: string + format: uuid + description: Public event token + - name: organizerToken + in: query + required: true + schema: + type: string + format: uuid + description: Organizer token for authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PatchEventRequest" + responses: + "204": + description: Event updated successfully + "403": + description: Invalid organizer token + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Event not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "409": + description: Event is already cancelled + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + components: schemas: CreateEventRequest: @@ -252,6 +304,7 @@ components: - dateTime - timezone - attendeeCount + - cancelled properties: eventToken: type: string @@ -284,6 +337,31 @@ components: minimum: 0 description: Number of confirmed attendees (attending=true) example: 12 + cancelled: + type: boolean + description: Whether the event has been cancelled + example: false + cancellationReason: + type: + - string + - "null" + description: Reason for cancellation, if provided + example: null + + PatchEventRequest: + type: object + required: + - cancelled + properties: + cancelled: + type: boolean + description: Set to true to cancel the event (irreversible) + example: true + cancellationReason: + type: string + maxLength: 2000 + description: Optional cancellation reason + example: "Unfortunately the venue is no longer available." CreateRsvpRequest: type: object 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 7a44f46..d94aaca 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 @@ -3,6 +3,7 @@ package de.fete.adapter.in.web; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -21,6 +22,7 @@ import de.fete.adapter.out.persistence.RsvpJpaRepository; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -431,6 +433,147 @@ class EventControllerIntegrationTest { .andExpect(jsonPath("$.attendeeCount").value(1)); } + // --- Cancel Event tests --- + + @Test + void cancelEventReturns204AndPersists() throws Exception { + EventJpaEntity event = seedEvent( + "Cancel Me", null, "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + var body = Map.of( + "cancelled", true, + "cancellationReason", "Venue closed"); + + mockMvc.perform(patch("/api/events/" + event.getEventToken() + + "?organizerToken=" + event.getOrganizerToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isNoContent()); + + EventJpaEntity persisted = jpaRepository + .findByEventToken(event.getEventToken()).orElseThrow(); + assertThat(persisted.isCancelled()).isTrue(); + assertThat(persisted.getCancellationReason()).isEqualTo("Venue closed"); + } + + @Test + void cancelEventWithoutReasonReturns204() throws Exception { + EventJpaEntity event = seedEvent( + "Cancel No Reason", null, "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + var body = Map.of("cancelled", true); + + mockMvc.perform(patch("/api/events/" + event.getEventToken() + + "?organizerToken=" + event.getOrganizerToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isNoContent()); + + EventJpaEntity persisted = jpaRepository + .findByEventToken(event.getEventToken()).orElseThrow(); + assertThat(persisted.isCancelled()).isTrue(); + assertThat(persisted.getCancellationReason()).isNull(); + } + + @Test + void cancelEventWithWrongOrganizerTokenReturns403() throws Exception { + EventJpaEntity event = seedEvent( + "Wrong Token", null, "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + var body = Map.of("cancelled", true); + + mockMvc.perform(patch("/api/events/" + event.getEventToken() + + "?organizerToken=" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isForbidden()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:invalid-organizer-token")); + + assertThat(jpaRepository.findByEventToken(event.getEventToken()) + .orElseThrow().isCancelled()).isFalse(); + } + + @Test + void cancelEventNotFoundReturns404() throws Exception { + var body = Map.of("cancelled", true); + + mockMvc.perform(patch("/api/events/" + UUID.randomUUID() + + "?organizerToken=" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found")); + } + + @Test + void cancelAlreadyCancelledEventReturns409() throws Exception { + EventJpaEntity event = seedCancelledEvent("Already Cancelled"); + + var body = Map.of("cancelled", true); + + mockMvc.perform(patch("/api/events/" + event.getEventToken() + + "?organizerToken=" + event.getOrganizerToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isConflict()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:event-already-cancelled")); + } + + @Test + void getEventReturnsCancelledFields() throws Exception { + EventJpaEntity event = seedCancelledEvent("Weather Event"); + + mockMvc.perform(get("/api/events/" + event.getEventToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.cancelled").value(true)) + .andExpect(jsonPath("$.cancellationReason").value("Cancelled")); + } + + @Test + void getEventReturnsNotCancelledByDefault() throws Exception { + EventJpaEntity event = seedEvent( + "Active Event", null, "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + mockMvc.perform(get("/api/events/" + event.getEventToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.cancelled").value(false)) + .andExpect(jsonPath("$.cancellationReason").doesNotExist()); + } + + @Test + void createRsvpOnCancelledEventReturns409() throws Exception { + EventJpaEntity event = seedCancelledEvent("Cancelled RSVP"); + long countBefore = rsvpJpaRepository.count(); + + 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-cancelled")); + + assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore); + } + + private EventJpaEntity seedCancelledEvent(String title) { + var entity = new EventJpaEntity(); + entity.setEventToken(UUID.randomUUID()); + entity.setOrganizerToken(UUID.randomUUID()); + entity.setTitle(title); + entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); + entity.setTimezone("Europe/Berlin"); + entity.setExpiryDate(LocalDate.now().plusDays(30)); + entity.setCreatedAt(OffsetDateTime.now()); + entity.setCancelled(true); + entity.setCancellationReason("Cancelled"); + return jpaRepository.save(entity); + } + private UUID seedRsvpAndGetToken(EventJpaEntity event, String name) { var rsvp = new RsvpJpaEntity(); UUID token = UUID.randomUUID(); diff --git a/backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java b/backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java new file mode 100644 index 0000000..6fa6c43 --- /dev/null +++ b/backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java @@ -0,0 +1,138 @@ +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.fete.application.service.EventAlreadyCancelledException; +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.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +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 EventServiceCancelTest { + + 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; + + private EventService eventService; + + @BeforeEach + void setUp() { + eventService = new EventService(eventRepository, FIXED_CLOCK); + } + + @Test + void cancelEventDelegatesToDomainAndSaves() { + EventToken eventToken = EventToken.generate(); + OrganizerToken organizerToken = OrganizerToken.generate(); + var event = new Event(); + event.setEventToken(eventToken); + event.setOrganizerToken(organizerToken); + event.setCancelled(false); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.of(event)); + when(eventRepository.save(any(Event.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + eventService.cancelEvent(eventToken, organizerToken, true, "Venue unavailable"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Event.class); + verify(eventRepository).save(captor.capture()); + assertThat(captor.getValue().isCancelled()).isTrue(); + assertThat(captor.getValue().getCancellationReason()).isEqualTo("Venue unavailable"); + } + + @Test + void cancelEventWithNullReason() { + EventToken eventToken = EventToken.generate(); + OrganizerToken organizerToken = OrganizerToken.generate(); + var event = new Event(); + event.setEventToken(eventToken); + event.setOrganizerToken(organizerToken); + event.setCancelled(false); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.of(event)); + when(eventRepository.save(any(Event.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + eventService.cancelEvent(eventToken, organizerToken, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Event.class); + verify(eventRepository).save(captor.capture()); + assertThat(captor.getValue().isCancelled()).isTrue(); + assertThat(captor.getValue().getCancellationReason()).isNull(); + } + + @Test + void cancelEventThrows404WhenNotFound() { + EventToken eventToken = EventToken.generate(); + OrganizerToken organizerToken = OrganizerToken.generate(); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null)) + .isInstanceOf(EventNotFoundException.class); + + verify(eventRepository, never()).save(any()); + } + + @Test + void cancelEventThrows403WhenWrongOrganizerToken() { + EventToken eventToken = EventToken.generate(); + OrganizerToken correctToken = OrganizerToken.generate(); + var event = new Event(); + event.setEventToken(eventToken); + event.setOrganizerToken(correctToken); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.of(event)); + + final OrganizerToken wrongToken = OrganizerToken.generate(); + assertThatThrownBy(() -> eventService.cancelEvent(eventToken, wrongToken, true, null)) + .isInstanceOf(InvalidOrganizerTokenException.class); + + verify(eventRepository, never()).save(any()); + } + + @Test + void cancelEventThrows409WhenAlreadyCancelled() { + EventToken eventToken = EventToken.generate(); + OrganizerToken organizerToken = OrganizerToken.generate(); + var event = new Event(); + event.setEventToken(eventToken); + event.setOrganizerToken(organizerToken); + event.setCancelled(true); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.of(event)); + + assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null)) + .isInstanceOf(EventAlreadyCancelledException.class); + + verify(eventRepository, never()).save(any()); + } +} diff --git a/frontend/e2e/cancel-event.spec.ts b/frontend/e2e/cancel-event.spec.ts new file mode 100644 index 0000000..9ba73e3 --- /dev/null +++ b/frontend/e2e/cancel-event.spec.ts @@ -0,0 +1,162 @@ +import { http, HttpResponse } from 'msw' +import { test, expect } from './msw-setup' +import type { StoredEvent } from '../src/composables/useEventStorage' + +const STORAGE_KEY = 'fete:events' + +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, + cancelled: false, + cancellationReason: null, +} + +const organizerToken = '550e8400-e29b-41d4-a716-446655440001' + +function seedEvents(events: StoredEvent[]): string { + return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})` +} + +function organizerSeed(): StoredEvent { + return { + eventToken: fullEvent.eventToken, + organizerToken, + title: fullEvent.title, + dateTime: fullEvent.dateTime, + } +} + +test.describe('US1: Organizer cancels event with reason', () => { + test('organizer opens cancel bottom sheet, enters reason, confirms — event shows as cancelled on reload', async ({ + page, + network, + }) => { + let cancelled = false + network.use( + http.get('*/api/events/:token', () => { + if (cancelled) { + return HttpResponse.json({ + ...fullEvent, + cancelled: true, + cancellationReason: 'Venue closed', + }) + } + return HttpResponse.json(fullEvent) + }), + http.patch('*/api/events/:token', ({ request }) => { + const url = new URL(request.url) + const token = url.searchParams.get('organizerToken') + if (token === organizerToken) { + cancelled = true + return new HttpResponse(null, { status: 204 }) + } + return HttpResponse.json( + { type: 'urn:problem-type:invalid-organizer-token', title: 'Forbidden', status: 403 }, + { status: 403 }, + ) + }), + ) + await page.addInitScript(seedEvents([organizerSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + // Cancel button visible for organizer + const cancelBtn = page.getByRole('button', { name: /Cancel event/i }) + await expect(cancelBtn).toBeVisible() + + // Open cancel bottom sheet + await cancelBtn.click() + + // Fill in reason + const reasonField = page.getByLabel(/reason/i) + await expect(reasonField).toBeVisible() + await reasonField.fill('Venue closed') + + // Confirm cancellation + await page.getByRole('button', { name: /Confirm cancellation/i }).click() + + // Event should show as cancelled + await expect(page.getByText(/This event has been cancelled/i)).toBeVisible() + await expect(page.getByText('Venue closed')).toBeVisible() + + // Cancel button should be gone + await expect(cancelBtn).not.toBeVisible() + }) +}) + +test.describe('US1: Organizer cancels event without reason', () => { + test('organizer cancels without reason — event shows as cancelled', async ({ + page, + network, + }) => { + let cancelled = false + network.use( + http.get('*/api/events/:token', () => { + if (cancelled) { + return HttpResponse.json({ + ...fullEvent, + cancelled: true, + cancellationReason: null, + }) + } + return HttpResponse.json(fullEvent) + }), + http.patch('*/api/events/:token', ({ request }) => { + const url = new URL(request.url) + const token = url.searchParams.get('organizerToken') + if (token === organizerToken) { + cancelled = true + return new HttpResponse(null, { status: 204 }) + } + return HttpResponse.json({}, { status: 403 }) + }), + ) + await page.addInitScript(seedEvents([organizerSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + await page.getByRole('button', { name: /Cancel event/i }).click() + + // Don't fill in reason, just confirm + await page.getByRole('button', { name: /Confirm cancellation/i }).click() + + // Event should show as cancelled without reason text + await expect(page.getByText(/This event has been cancelled/i)).toBeVisible() + }) +}) + +test.describe('US1: Cancel API failure', () => { + test('cancel API fails — error displayed in bottom sheet, button re-enabled for retry', async ({ + page, + network, + }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + http.patch('*/api/events/:token', () => { + return HttpResponse.json( + { + type: 'about:blank', + title: 'Internal Server Error', + status: 500, + detail: 'Something went wrong', + }, + { status: 500, headers: { 'Content-Type': 'application/problem+json' } }, + ) + }), + ) + await page.addInitScript(seedEvents([organizerSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + await page.getByRole('button', { name: /Cancel event/i }).click() + await page.getByRole('button', { name: /Confirm cancellation/i }).click() + + // Error message in bottom sheet + await expect(page.getByText(/Could not cancel event/i)).toBeVisible() + + // Confirm button should be re-enabled + await expect(page.getByRole('button', { name: /Confirm cancellation/i })).toBeEnabled() + }) +}) diff --git a/frontend/e2e/cancelled-event-visitor.spec.ts b/frontend/e2e/cancelled-event-visitor.spec.ts new file mode 100644 index 0000000..83223a1 --- /dev/null +++ b/frontend/e2e/cancelled-event-visitor.spec.ts @@ -0,0 +1,74 @@ +import { http, HttpResponse } from 'msw' +import { test, expect } from './msw-setup' + +const cancelledEventWithReason = { + 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, + cancelled: true, + cancellationReason: 'Venue no longer available', +} + +const cancelledEventWithoutReason = { + ...cancelledEventWithReason, + cancellationReason: null, +} + +test.describe('US2: Visitor sees cancelled event with reason', () => { + test('visitor sees red banner with cancellation reason on cancelled event', async ({ + page, + network, + }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)), + ) + + await page.goto(`/events/${cancelledEventWithReason.eventToken}`) + + await expect(page.getByText(/This event has been cancelled/i)).toBeVisible() + await expect(page.getByText('Venue no longer available')).toBeVisible() + }) +}) + +test.describe('US2: Visitor sees cancelled event without reason', () => { + test('visitor sees red banner without reason when no reason was provided', async ({ + page, + network, + }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithoutReason)), + ) + + await page.goto(`/events/${cancelledEventWithoutReason.eventToken}`) + + await expect(page.getByText(/This event has been cancelled/i)).toBeVisible() + // No reason text shown + await expect(page.getByText('Venue no longer available')).not.toBeVisible() + }) +}) + +test.describe('US2: RSVP buttons hidden on cancelled event', () => { + test('RSVP buttons hidden on cancelled event, other details remain visible', async ({ + page, + network, + }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)), + ) + + await page.goto(`/events/${cancelledEventWithReason.eventToken}`) + + // Event details are still visible + await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible() + await expect(page.getByText('Bring your own drinks!')).toBeVisible() + await expect(page.getByText('Central Park, NYC')).toBeVisible() + await expect(page.getByText('12 going')).toBeVisible() + + // RSVP bar is NOT visible + 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 224fd1c..769e874 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -18,6 +18,14 @@ --color-card: #ffffff; --color-dark-base: #1B1730; + /* Danger / destructive actions */ + --color-danger: #fca5a5; + --color-danger-bg: rgba(220, 38, 38, 0.15); + --color-danger-bg-hover: rgba(220, 38, 38, 0.25); + --color-danger-bg-strong: rgba(220, 38, 38, 0.2); + --color-danger-border: rgba(220, 38, 38, 0.3); + --color-danger-border-strong: rgba(220, 38, 38, 0.4); + /* Glass system */ --color-glass: rgba(255, 255, 255, 0.1); --color-glass-strong: rgba(255, 255, 255, 0.15); diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue index 4f0a0ee..2a342c6 100644 --- a/frontend/src/views/EventDetailView.vue +++ b/frontend/src/views/EventDetailView.vue @@ -25,6 +25,12 @@
+ + +

{{ event.title }}

@@ -70,14 +76,49 @@
- + +
+ +
+ + + +

Cancel event

+
+
+ +