Add spec, plan, and tasks for 016-cancel-event feature

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 18:50:46 +01:00
parent bf0f4ffb7f
commit 3908c89998
8 changed files with 674 additions and 0 deletions

View File

@@ -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.