Compare commits
101 Commits
0.2.0
...
renovate/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ee2c89ec | ||
| b12106d3bf | |||
| d0ed6790ef | |||
| 92372b6a59 | |||
| 7817ad182b | |||
| 9483e9b1f7 | |||
| 75e6548403 | |||
| d4a1f0dc23 | |||
| 3d7efb14f7 | |||
| 2f8b911af8 | |||
| e9791de4e2 | |||
| 3b4cc7fbb9 | |||
| 9c0e9249ce | |||
| 5082ec1333 | |||
| 35b488a8be | |||
| b067c0ef1e | |||
|
|
42686502d8 | ||
| 51ab99fc61 | |||
| d52f51d6e1 | |||
| c1760ae376 | |||
| 6d51327e56 | |||
| 96044ae1ed | |||
| f972a41e45 | |||
| 13b01dfba8 | |||
| fd8724db8f | |||
| 8885dbd722 | |||
| c51eacb261 | |||
| c450849e4d | |||
| e01d5ee642 | |||
| d333ab3d39 | |||
| 541017965f | |||
| 981920f004 | |||
| 3908c89998 | |||
| bf0f4ffb7f | |||
| 58043d1507 | |||
|
|
264c4ec21f | ||
| 6d7a55fdb3 | |||
| a8aacf4ee9 | |||
| 0a404ecde3 | |||
| 01f9e3dac1 | |||
| ad607afe83 | |||
| f0424223de | |||
| 7ab9068c14 | |||
| 41bb17d5c9 | |||
|
|
a44b938f08 | ||
|
|
7477a953c5 | ||
|
|
7fb296b47f | ||
|
|
8ab7d345c8 | ||
|
|
cf2139f229 | ||
|
|
79f33d659c | ||
| e5b71f8fb8 | |||
|
|
60649ae4de | ||
| e90aefae15 | |||
|
|
622932418d | ||
| a1855ff8d6 | |||
| 4bfaee685c | |||
| 2a6a658df9 | |||
| 37d378ca59 | |||
| 0441ca0c33 | |||
|
|
e6711b33d4 | ||
| 6b3a06a72c | |||
| 448e801ca3 | |||
| 751201617d | |||
| fa34223c10 | |||
| e6ea9405a6 | |||
| 32f96e4c6f | |||
| e6c4a21f65 | |||
| 831ffc071a | |||
| 5dd7cb3fb8 | |||
| 64816558c1 | |||
| 019ead7be3 | |||
| 29974704d0 | |||
| 877c869a22 | |||
| a9743025a7 | |||
| 9f82275c63 | |||
| e203ecf687 | |||
| aa3ea04bfc | |||
|
|
27ca8ab4b8 | ||
| 752d153cd4 | |||
| 763811fce6 | |||
| d7ed28e036 | |||
| a52d0cd1d3 | |||
| 373f3671f6 | |||
| 8f78c6cd45 | |||
| fe291e36e4 | |||
| e56998b17c | |||
| 1b3eafa8d1 | |||
| 061d507825 | |||
| d79a19ca15 | |||
| 2da36058ae | |||
| 90bfd12bf3 | |||
| 4d6df8d16b | |||
| be1c5062a2 | |||
| d9136481d8 | |||
| e248a2ee06 | |||
| fc77248c38 | |||
| a625e34fe4 | |||
| 4828d06aba | |||
| cac2903807 | |||
|
|
210118bf9a | ||
| 9a78ebd9b0 |
@@ -26,7 +26,7 @@ PASSED=""
|
|||||||
|
|
||||||
# Run backend tests if Java sources changed
|
# Run backend tests if Java sources changed
|
||||||
if [[ -n "$HAS_BACKEND" ]]; then
|
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. "
|
PASSED+="✓ Backend tests passed. "
|
||||||
else
|
else
|
||||||
# Filter: only [ERROR] lines, skip Maven boilerplate
|
# Filter: only [ERROR] lines, skip Maven boilerplate
|
||||||
|
|||||||
94
.claude/skills/merge-pr/SKILL.md
Normal file
94
.claude/skills/merge-pr/SKILL.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: merge-pr
|
||||||
|
description: Create a Gitea pull request, monitor CI pipeline status, and merge when green. Use this skill when the user asks to "create a PR", "merge the PR", "ship it", "make it ready to merge", or when you need to open a pull request and wait for CI before merging. Also use when asked to check CI/PR status on Gitea.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Merge PR
|
||||||
|
|
||||||
|
Create a pull request on Gitea, monitor the CI pipeline via the Actions API, and merge once all jobs pass.
|
||||||
|
|
||||||
|
## Why this skill exists
|
||||||
|
|
||||||
|
The Gitea MCP pull request API does not return CI status directly. To know if a PR is ready to merge, you must cross-reference the PR's `head.sha` with the Actions runs API, find the matching run, and check job conclusions. This skill encodes that workflow so it doesn't have to be rediscovered.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
The Gitea MCP tools must be available. The key tools are:
|
||||||
|
|
||||||
|
- `mcp__gitea__pull_request_write` (method: `create`, `merge`)
|
||||||
|
- `mcp__gitea__pull_request_read` (method: `get`)
|
||||||
|
- `mcp__gitea__actions_run_read` (methods: `list_runs`, `list_run_jobs`)
|
||||||
|
|
||||||
|
If these tools are not yet loaded, use ToolSearch to discover and load them before proceeding.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Create the PR
|
||||||
|
|
||||||
|
Use `mcp__gitea__pull_request_write` with method `create`. Include a clear title, body with summary and test plan, head branch, and base branch (usually `master`).
|
||||||
|
|
||||||
|
Save the returned `head.sha` — you need it to find the CI run.
|
||||||
|
|
||||||
|
### 2. Find the CI run for the PR
|
||||||
|
|
||||||
|
The Actions API has no direct "get CI status for PR" call. Instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__gitea__actions_run_read(method: "list_runs", owner, repo, perPage: 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the run whose `head_sha` matches the PR's `head.sha`. This is the CI run triggered by the push that the PR points to. If the branch was force-pushed or new commits were added, always match against the latest `head.sha` from a fresh `get` on the PR.
|
||||||
|
|
||||||
|
### 3. Monitor job status
|
||||||
|
|
||||||
|
Once you have the run ID:
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__gitea__actions_run_read(method: "list_run_jobs", owner, repo, run_id: <id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns all jobs with their `status` (queued/in_progress/completed) and `conclusion` (success/failure/skipped/null).
|
||||||
|
|
||||||
|
Present a status table to the user:
|
||||||
|
|
||||||
|
| Job | Status |
|
||||||
|
|-----|--------|
|
||||||
|
| backend-test | success |
|
||||||
|
| frontend-test | in_progress |
|
||||||
|
| frontend-e2e | queued |
|
||||||
|
|
||||||
|
If jobs are still running, wait ~30 seconds and check again. Don't poll in a tight loop.
|
||||||
|
|
||||||
|
### 4. Handle failures
|
||||||
|
|
||||||
|
If any job has `conclusion: failure`:
|
||||||
|
- Use `mcp__gitea__actions_run_read` with method `get_job_log_preview` to fetch the failing job's log
|
||||||
|
- Report the failure to the user with relevant log output
|
||||||
|
- Do NOT attempt to merge
|
||||||
|
|
||||||
|
### 5. Merge when green
|
||||||
|
|
||||||
|
Once all jobs show `conclusion: success` (or `skipped` for conditional jobs like `build-and-publish`):
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__gitea__pull_request_write(
|
||||||
|
method: "merge",
|
||||||
|
owner, repo,
|
||||||
|
index: <pr_number>,
|
||||||
|
merge_style: "merge",
|
||||||
|
delete_branch: true
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ask the user for confirmation before merging. They may want to review the PR in the web UI first.
|
||||||
|
|
||||||
|
### 6. Post-merge cleanup
|
||||||
|
|
||||||
|
After a successful merge, suggest:
|
||||||
|
- `git checkout master && git pull origin master`
|
||||||
|
- `git branch -d <feature-branch>` (local cleanup)
|
||||||
|
- Tagging a release if appropriate (see `/release` skill)
|
||||||
|
|
||||||
|
## Abbreviated flow
|
||||||
|
|
||||||
|
When the user just wants a quick status check (e.g. "how's the PR?"), skip straight to steps 2-3: find the run by SHA, show the job status table.
|
||||||
@@ -79,6 +79,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Parse SemVer tag
|
- name: Parse SemVer tag
|
||||||
id: semver
|
id: semver
|
||||||
@@ -114,3 +116,22 @@ jobs:
|
|||||||
docker push "${IMAGE}:${{ steps.semver.outputs.minor }}"
|
docker push "${IMAGE}:${{ steps.semver.outputs.minor }}"
|
||||||
docker push "${IMAGE}:${{ steps.semver.outputs.major }}"
|
docker push "${IMAGE}:${{ steps.semver.outputs.major }}"
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
|
|
||||||
|
- name: Generate changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p')
|
||||||
|
if [ -z "$PREV_TAG" ]; then
|
||||||
|
git log --oneline --no-merges > RELEASE_NOTES.md
|
||||||
|
else
|
||||||
|
git log --oneline --no-merges "${PREV_TAG}..HEAD" > RELEASE_NOTES.md
|
||||||
|
fi
|
||||||
|
echo "Container image: \`${IMAGE}:${{ steps.semver.outputs.full }}\`" >> RELEASE_NOTES.md
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
name: v${{ steps.semver.outputs.full }}
|
||||||
|
body_path: RELEASE_NOTES.md
|
||||||
|
token: ${{ github.token }}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Person erstellt via App eine Veranstaltung und schickt seine Freunden irgendwie
|
|||||||
* Updaten der Veranstaltung
|
* Updaten der Veranstaltung
|
||||||
* Einsicht angemeldete Gäste, kann bei Bedarf Einträge entfernen
|
* Einsicht angemeldete Gäste, kann bei Bedarf Einträge entfernen
|
||||||
* Featureideen:
|
* 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.
|
* 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
|
* 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
|
* Ä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)
|
* QR Code generieren (z.B. für Plakate/Flyer)
|
||||||
* Ablaufdatum als Pflichtfeld, nach dem alle gespeicherten Daten gelöscht werden
|
* Ablaufdatum als Pflichtfeld, nach dem alle gespeicherten Daten gelöscht werden
|
||||||
* Übersichtsliste im LocalStorage: Alle Events die man zugesagt oder gemerkt hat (vgl. spliit)
|
* Ü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:
|
* Sicherheit/Missbrauchsschutz:
|
||||||
* Nicht-erratbare Event-Tokens (z.B. UUIDs)
|
* Nicht-erratbare Event-Tokens (z.B. UUIDs)
|
||||||
* Event-Erstellung ist offen, kein Login/Passwort/Invite-Code nötig
|
* Event-Erstellung ist offen, kein Login/Passwort/Invite-Code nötig
|
||||||
@@ -79,3 +82,66 @@ Die folgenden Punkte wurden in Diskussionen bereits geklärt und sind verbindlic
|
|||||||
* Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router)
|
* Frontend: Vue 3 (mit Vite als Bundler, TypeScript, Vue Router)
|
||||||
* Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!):
|
* Architekturentscheidungen die NOCH NICHT getroffen wurden (hier darf nichts eigenmächtig entschieden werden!):
|
||||||
* (derzeit keine offenen Architekturentscheidungen)
|
* (derzeit keine offenen Architekturentscheidungen)
|
||||||
|
|
||||||
|
## Nicht umgesetzte Feature-Ideen (ehemals Specs 009–026)
|
||||||
|
### 010 – Event bearbeiten
|
||||||
|
Organisator kann Titel, Beschreibung, Datum, Ort und Ablaufdatum ändern.
|
||||||
|
* Formular vorausgefüllt mit aktuellen Werten
|
||||||
|
* Ablaufdatum muss in der Zukunft liegen
|
||||||
|
* Ohne Organizer-Token kein Edit-UI sichtbar
|
||||||
|
|
||||||
|
### 013 – Kalender-Export
|
||||||
|
.ics-Download (RFC 5545) mit Event-Details, optional webcal:// für Live-Updates.
|
||||||
|
* Stabile UID aus Event-Token (Re-Import aktualisiert statt dupliziert)
|
||||||
|
* Bei Absage: STATUS:CANCELLED im .ics
|
||||||
|
* Kein externer Kalenderservice kontaktiert
|
||||||
|
|
||||||
|
### 014 – Änderungen hervorheben
|
||||||
|
Geänderte Felder werden visuell hervorgehoben, wenn der Gast seit der letzten Änderung nicht mehr auf der Seite war.
|
||||||
|
* Server trackt `last_edited_at` + geänderte Feldnamen
|
||||||
|
* Client speichert `last_seen_at` in localStorage
|
||||||
|
* Privacy-freundlich: kein serverseitiges Read-Tracking
|
||||||
|
|
||||||
|
### 015 – Organisator-Updates
|
||||||
|
Organisator kann Textnachrichten im Event posten (Pinnwand-Stil).
|
||||||
|
* Chronologisch sortiert, löschbar durch Organisator
|
||||||
|
* Nach Ablauf kein Posting mehr möglich
|
||||||
|
* Ohne Organizer-Token kein Compose-UI
|
||||||
|
|
||||||
|
### 016 – Gast-Benachrichtigungen
|
||||||
|
Badge/Indikator bei ungelesenen Organisator-Updates, rein clientseitig via localStorage.
|
||||||
|
* Eigener Timestamp `updates_last_seen_at` (getrennt von Feld-Änderungen)
|
||||||
|
* Kein Indikator beim ersten Besuch
|
||||||
|
* Kein serverseitiges Tracking (Privacy)
|
||||||
|
|
||||||
|
### 017 – QR-Code
|
||||||
|
Event-Seite zeigt QR-Code mit der öffentlichen Event-URL.
|
||||||
|
* Serverseitig generiert (kein externer QR-Service)
|
||||||
|
* Download als SVG oder hochauflösendes PNG
|
||||||
|
|
||||||
|
### 020 – PWA
|
||||||
|
Web App Manifest + Service Worker für Installierbarkeit und Offline-Caching.
|
||||||
|
* Standalone-Modus ohne Browser-Chrome
|
||||||
|
* Icon + Name auf Home-Screen
|
||||||
|
* Alle Assets selbstgehostet
|
||||||
|
|
||||||
|
### 021 – Farbthemen
|
||||||
|
Organisator wählt bei Erstellung ein vordefiniertes Farbthema für die Event-Seite.
|
||||||
|
* Nur auf der Gast-Seite angewendet (nicht global)
|
||||||
|
* Änderbar beim Bearbeiten
|
||||||
|
* Unabhängig von Dark/Light Mode
|
||||||
|
|
||||||
|
### 022 – Headerbild
|
||||||
|
Organisator sucht Headerbild über integrierte Unsplash-Suche.
|
||||||
|
* Serverseitig geproxied (Client kontaktiert nie Unsplash)
|
||||||
|
* Bild lokal gespeichert + Unsplash-Attribution
|
||||||
|
* Feature deaktiviert wenn kein API-Key konfiguriert
|
||||||
|
|
||||||
|
### 026 – 404-Seite
|
||||||
|
Catch-all Route für ungültige Pfade mit "Seite nicht gefunden" und Link zur Startseite.
|
||||||
|
* Folgt dem Design System (Electric Dusk + Sora)
|
||||||
|
* WCAG AA konform
|
||||||
|
* Verhindert leere Seiten bei Fehlnavigation
|
||||||
|
|
||||||
|
### 027 - Update der EventListe
|
||||||
|
* Irgendwie ein update der event liste, wenn man sie betritt oder wenn man mit touch die seite nach unten zieht (hier müssen wir noch überlegen, wie wir mit den verschiedenen update fällen umgehen und wie wir das update überhaupt requesten. Ich meine sowas wie: was ist, wenn das event nicht mehr gefunden wurde?)
|
||||||
|
|||||||
37
.specify/memory/research/modern-ui-effects.md
Normal file
37
.specify/memory/research/modern-ui-effects.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Modern UI Effects Research (2025-2026)
|
||||||
|
|
||||||
|
## Liquid Glass (Apple WWDC 2025)
|
||||||
|
Evolved glassmorphism with directional lighting. Three-layer approach: highlight, shadow, illumination.
|
||||||
|
- `backdrop-filter: blur(20px) saturate(1.5)` — higher saturation than basic glass
|
||||||
|
- `inset 0 1px 0 rgba(255,255,255,0.15)` — top highlight (light direction)
|
||||||
|
- `inset 0 -1px 0 rgba(0,0,0,0.1)` — bottom shadow
|
||||||
|
- Outer drop shadow for depth: `0 8px 32px rgba(0,0,0,0.3)`
|
||||||
|
- Advanced: SVG `feTurbulence` + `feSpecularLighting` for refraction (Chromium only)
|
||||||
|
- Browser support: `backdrop-filter` ~88%, Firefox since v103
|
||||||
|
|
||||||
|
## Aurora / Gradient Mesh Backgrounds
|
||||||
|
Stacked animated radial gradients simulating northern lights. Pairs well with glass cards on dark backgrounds.
|
||||||
|
- Multiple `radial-gradient(ellipse ...)` layers with partial opacity
|
||||||
|
- Animated via `background-position` shift (GPU-friendly)
|
||||||
|
- `@property` rule enables direct gradient color animation (broad support since 2024)
|
||||||
|
- Best for ambient background movement, not for content areas
|
||||||
|
|
||||||
|
## Animated Glow Borders
|
||||||
|
Rotating `conic-gradient` borders with blur halo. Striking on dark backgrounds.
|
||||||
|
- Outer wrapper with `conic-gradient(from var(--angle), color1, color2, color3, color1)`
|
||||||
|
- `::before` pseudo with `filter: blur(12px)` and `opacity: 0.5` for glow halo
|
||||||
|
- `@property --angle` trick to animate custom property inside `conic-gradient`
|
||||||
|
- Use sparingly — best for single highlight elements (FAB, CTA), not all cards
|
||||||
|
|
||||||
|
## Modern Neumorphism (2025-2026 revision)
|
||||||
|
Subtler than the original trend. Higher contrast, less extreme extrusion, combined with accent colors.
|
||||||
|
- Light and dark shadow pair: `6px 6px 12px rgba(0,0,0,0.5)` + `-6px -6px 12px rgba(60,50,80,0.15)`
|
||||||
|
- `border: 1px solid rgba(255,255,255,0.05)` for definition
|
||||||
|
- Works on dark backgrounds with slightly lighter "uplift" shadow direction
|
||||||
|
- Better suited for interactive elements (buttons, toggles) than content cards
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
- Apple Liquid Glass CSS: dev.to/gruszdev, dev.to/kevinbism, css-tricks.com, kube.io
|
||||||
|
- Aurora: dev.to/oobleck, daltonwalsh.com, github.com/mattnewdavid
|
||||||
|
- Glow borders: frontendmasters.com (Kevin Powell), docode.co.in
|
||||||
|
- Trends overview: medium.com/design-bootcamp, index.dev, bighuman.com
|
||||||
@@ -51,8 +51,8 @@ The following skills are available and should be used for their respective purpo
|
|||||||
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
|
- Project specifications (user stories, setup tasks, personas, etc.) live in `specs/` (feature dirs) and `.specify/memory/` (cross-cutting docs).
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript (007-view-event)
|
- TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite (018-cancel-event-list)
|
||||||
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
- localStorage via `useEventStorage()` composable (018-cancel-event-list)
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
- 018-cancel-event-list: Added TypeScript 5.x, Vue 3 (Composition API) + openapi-fetch, Vue Router, Vite
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Build frontend
|
# Stage 1: Build frontend
|
||||||
FROM node:24-alpine AS frontend-build
|
FROM node:25-alpine AS frontend-build
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -1,6 +1,25 @@
|
|||||||
# fete
|
<p align="center">
|
||||||
|
<img src="frontend/public/og-image.png" alt="fete" width="100%" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A privacy-focused, self-hostable web app for event announcements and RSVPs. An alternative to Facebook Events or Telegram groups — reduced to the essentials.
|
<p align="center">
|
||||||
|
<strong>Privacy-focused, self-hostable event announcements and RSVPs.</strong><br>
|
||||||
|
An alternative to Facebook Events or Telegram groups — reduced to the essentials.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/01-create-event.png" alt="Create Event" width="230" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/02-event-detail.png" alt="Event Detail" width="230" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/03-rsvp.png" alt="RSVP" width="230" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Create events · Share with guests · Collect RSVPs</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
wrapperVersion=3.3.4
|
wrapperVersion=3.3.4
|
||||||
distributionType=only-script
|
distributionType=only-script
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||||
|
|||||||
@@ -7,4 +7,8 @@
|
|||||||
<Match>
|
<Match>
|
||||||
<Package name="de.fete.adapter.in.web.model"/>
|
<Package name="de.fete.adapter.in.web.model"/>
|
||||||
</Match>
|
</Match>
|
||||||
|
<!-- Constructor-injected Spring beans storing interfaces/proxies are not a real exposure risk -->
|
||||||
|
<Match>
|
||||||
|
<Bug pattern="EI_EXPOSE_REP2"/>
|
||||||
|
</Match>
|
||||||
</FindBugsFilter>
|
</FindBugsFilter>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package de.fete;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/** Spring Boot entry point for the fete application. */
|
/** Spring Boot entry point for the fete application. */
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
public class FeteApplication {
|
public class FeteApplication {
|
||||||
|
|
||||||
/** Starts the application. */
|
/** Starts the application. */
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
import de.fete.adapter.in.web.api.EventsApi;
|
import de.fete.adapter.in.web.api.EventsApi;
|
||||||
|
import de.fete.adapter.in.web.model.Attendee;
|
||||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
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.GetAttendeesResponse;
|
||||||
import de.fete.adapter.in.web.model.GetEventResponse;
|
import de.fete.adapter.in.web.model.GetEventResponse;
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.adapter.in.web.model.PatchEventRequest;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidTimezoneException;
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
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.model.RsvpToken;
|
||||||
|
import de.fete.domain.port.in.CancelRsvpUseCase;
|
||||||
|
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||||
import de.fete.domain.port.in.CreateEventUseCase;
|
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.GetEventUseCase;
|
||||||
import java.time.Clock;
|
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||||
import java.time.DateTimeException;
|
import java.time.DateTimeException;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -25,16 +38,28 @@ public class EventController implements EventsApi {
|
|||||||
|
|
||||||
private final CreateEventUseCase createEventUseCase;
|
private final CreateEventUseCase createEventUseCase;
|
||||||
private final GetEventUseCase getEventUseCase;
|
private final GetEventUseCase getEventUseCase;
|
||||||
private final Clock clock;
|
private final CreateRsvpUseCase createRsvpUseCase;
|
||||||
|
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 and clock. */
|
/** Creates a new controller with the given use cases. */
|
||||||
public EventController(
|
public EventController(
|
||||||
CreateEventUseCase createEventUseCase,
|
CreateEventUseCase createEventUseCase,
|
||||||
GetEventUseCase getEventUseCase,
|
GetEventUseCase getEventUseCase,
|
||||||
Clock clock) {
|
CreateRsvpUseCase createRsvpUseCase,
|
||||||
|
CancelRsvpUseCase cancelRsvpUseCase,
|
||||||
|
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||||
|
GetAttendeesUseCase getAttendeesUseCase,
|
||||||
|
UpdateEventUseCase updateEventUseCase) {
|
||||||
this.createEventUseCase = createEventUseCase;
|
this.createEventUseCase = createEventUseCase;
|
||||||
this.getEventUseCase = getEventUseCase;
|
this.getEventUseCase = getEventUseCase;
|
||||||
this.clock = clock;
|
this.createRsvpUseCase = createRsvpUseCase;
|
||||||
|
this.cancelRsvpUseCase = cancelRsvpUseCase;
|
||||||
|
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||||
|
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||||
|
this.updateEventUseCase = updateEventUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -47,42 +72,91 @@ public class EventController implements EventsApi {
|
|||||||
request.getDescription(),
|
request.getDescription(),
|
||||||
request.getDateTime(),
|
request.getDateTime(),
|
||||||
zoneId,
|
zoneId,
|
||||||
request.getLocation(),
|
request.getLocation()
|
||||||
request.getExpiryDate()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Event event = createEventUseCase.createEvent(command);
|
Event event = createEventUseCase.createEvent(command);
|
||||||
|
|
||||||
var response = new CreateEventResponse();
|
var response = new CreateEventResponse();
|
||||||
response.setEventToken(event.getEventToken());
|
response.setEventToken(event.eventToken().value());
|
||||||
response.setOrganizerToken(event.getOrganizerToken());
|
response.setOrganizerToken(event.organizerToken().value());
|
||||||
response.setTitle(event.getTitle());
|
response.setTitle(event.title());
|
||||||
response.setDateTime(event.getDateTime());
|
response.setDateTime(event.dateTime());
|
||||||
response.setTimezone(event.getTimezone().getId());
|
response.setTimezone(event.timezone().getId());
|
||||||
response.setExpiryDate(event.getExpiryDate());
|
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
|
public ResponseEntity<GetEventResponse> getEvent(UUID eventToken) {
|
||||||
Event event = getEventUseCase.getByEventToken(token)
|
var evtToken = new EventToken(eventToken);
|
||||||
.orElseThrow(() -> new EventNotFoundException(token));
|
Event event = getEventUseCase.getByEventToken(evtToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken));
|
||||||
|
|
||||||
var response = new GetEventResponse();
|
var response = new GetEventResponse();
|
||||||
response.setEventToken(event.getEventToken());
|
response.setEventToken(event.eventToken().value());
|
||||||
response.setTitle(event.getTitle());
|
response.setTitle(event.title());
|
||||||
response.setDescription(event.getDescription());
|
response.setDescription(event.description());
|
||||||
response.setDateTime(event.getDateTime());
|
response.setDateTime(event.dateTime());
|
||||||
response.setTimezone(event.getTimezone().getId());
|
response.setTimezone(event.timezone().getId());
|
||||||
response.setLocation(event.getLocation());
|
response.setLocation(event.location());
|
||||||
response.setAttendeeCount(0);
|
response.setAttendeeCount(
|
||||||
response.setExpired(
|
(int) countAttendeesByEventUseCase.countByEvent(evtToken));
|
||||||
event.getExpiryDate().isBefore(LocalDate.now(clock)));
|
response.setCancelled(event.cancelled());
|
||||||
|
response.setCancellationReason(event.cancellationReason());
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> 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<GetAttendeesResponse> getAttendees(
|
||||||
|
UUID eventToken, UUID organizerToken) {
|
||||||
|
var evtToken = new EventToken(eventToken);
|
||||||
|
var orgToken = new OrganizerToken(organizerToken);
|
||||||
|
|
||||||
|
List<String> names = getAttendeesUseCase
|
||||||
|
.getAttendeeNames(evtToken, orgToken);
|
||||||
|
|
||||||
|
var attendees = names.stream()
|
||||||
|
.map(name -> new Attendee().name(name))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var response = new GetAttendeesResponse();
|
||||||
|
response.setAttendees(attendees);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<CreateRsvpResponse> createRsvp(
|
||||||
|
UUID eventToken, CreateRsvpRequest createRsvpRequest) {
|
||||||
|
var evtToken = new EventToken(eventToken);
|
||||||
|
Rsvp rsvp = createRsvpUseCase.createRsvp(evtToken, createRsvpRequest.getName());
|
||||||
|
|
||||||
|
var response = new CreateRsvpResponse();
|
||||||
|
response.setRsvpToken(rsvp.rsvpToken().value());
|
||||||
|
response.setName(rsvp.name());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> cancelRsvp(UUID eventToken, UUID rsvpToken) {
|
||||||
|
cancelRsvpUseCase.cancelRsvp(new EventToken(eventToken), new RsvpToken(rsvpToken));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
private static ZoneId parseTimezone(String timezone) {
|
private static ZoneId parseTimezone(String timezone) {
|
||||||
try {
|
try {
|
||||||
return ZoneId.of(timezone);
|
return ZoneId.of(timezone);
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||||
import de.fete.application.service.ExpiryDateInPastException;
|
import de.fete.application.service.exception.EventCancelledException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.exception.EventExpiredException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.ExpiryDateBeforeEventException;
|
||||||
|
import de.fete.application.service.exception.ExpiryDateInPastException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
|
import de.fete.application.service.exception.InvalidTimezoneException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -46,6 +51,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handles expiry date before event date. */
|
||||||
|
@ExceptionHandler(ExpiryDateBeforeEventException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> 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. */
|
/** Handles expiry date validation failures. */
|
||||||
@ExceptionHandler(ExpiryDateInPastException.class)
|
@ExceptionHandler(ExpiryDateInPastException.class)
|
||||||
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
|
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
|
||||||
@@ -59,6 +77,58 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
.body(problemDetail);
|
.body(problemDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handles attempt to cancel an already cancelled event. */
|
||||||
|
@ExceptionHandler(EventAlreadyCancelledException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> 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<ProblemDetail> 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<ProblemDetail> 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 invalid organizer token. */
|
||||||
|
@ExceptionHandler(InvalidOrganizerTokenException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleInvalidOrganizerToken(
|
||||||
|
InvalidOrganizerTokenException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.FORBIDDEN, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Forbidden");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:invalid-organizer-token"));
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
/** Handles event not found. */
|
/** Handles event not found. */
|
||||||
@ExceptionHandler(EventNotFoundException.class)
|
@ExceptionHandler(EventNotFoundException.class)
|
||||||
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
||||||
|
|||||||
188
backend/src/main/java/de/fete/adapter/in/web/SpaController.java
Normal file
188
backend/src/main/java/de/fete/adapter/in/web/SpaController.java
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||||
|
|
||||||
|
/** Serves the SPA index.html with injected Open Graph and Twitter Card meta-tags. */
|
||||||
|
@Controller
|
||||||
|
public class SpaController {
|
||||||
|
|
||||||
|
private static final String PLACEHOLDER = "<!-- OG_META_TAGS -->";
|
||||||
|
private static final int MAX_TITLE_LENGTH = 70;
|
||||||
|
private static final int MAX_DESCRIPTION_LENGTH = 200;
|
||||||
|
private static final String GENERIC_TITLE = "fete";
|
||||||
|
private static final String GENERIC_DESCRIPTION =
|
||||||
|
"Privacy-focused event planning. Create and share events without accounts.";
|
||||||
|
private static final DateTimeFormatter DATE_FORMAT =
|
||||||
|
DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH);
|
||||||
|
|
||||||
|
private final GetEventUseCase getEventUseCase;
|
||||||
|
private String htmlTemplate;
|
||||||
|
|
||||||
|
/** Creates a new SpaController. */
|
||||||
|
public SpaController(GetEventUseCase getEventUseCase) {
|
||||||
|
this.getEventUseCase = getEventUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads and caches the index.html template at startup. */
|
||||||
|
@PostConstruct
|
||||||
|
void loadTemplate() throws IOException {
|
||||||
|
var resource = new ClassPathResource("/static/index.html");
|
||||||
|
if (resource.exists()) {
|
||||||
|
htmlTemplate = resource.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serves SPA HTML with generic meta-tags for non-event routes. */
|
||||||
|
@GetMapping(
|
||||||
|
value = {"/", "/create", "/events"},
|
||||||
|
produces = MediaType.TEXT_HTML_VALUE
|
||||||
|
)
|
||||||
|
@ResponseBody
|
||||||
|
public String serveGenericPage(HttpServletRequest request) {
|
||||||
|
if (htmlTemplate == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String baseUrl = getBaseUrl(request);
|
||||||
|
return htmlTemplate.replace(PLACEHOLDER, renderTags(buildGenericMeta(baseUrl)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serves SPA HTML with event-specific meta-tags. */
|
||||||
|
@GetMapping(
|
||||||
|
value = "/events/{eventToken}",
|
||||||
|
produces = MediaType.TEXT_HTML_VALUE
|
||||||
|
)
|
||||||
|
@ResponseBody
|
||||||
|
public String serveEventPage(@PathVariable String eventToken,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
if (htmlTemplate == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String baseUrl = getBaseUrl(request);
|
||||||
|
Map<String, String> meta = resolveEventMeta(eventToken, baseUrl);
|
||||||
|
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Meta-tag composition ---
|
||||||
|
|
||||||
|
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
|
||||||
|
var tags = new LinkedHashMap<String, String>();
|
||||||
|
String title = truncateTitle(event.title());
|
||||||
|
String description = formatDescription(event);
|
||||||
|
tags.put("og:title", title);
|
||||||
|
tags.put("og:description", description);
|
||||||
|
tags.put("og:url", baseUrl + "/events/" + event.eventToken().value());
|
||||||
|
tags.put("og:type", "website");
|
||||||
|
tags.put("og:site_name", GENERIC_TITLE);
|
||||||
|
tags.put("og:image", baseUrl + "/og-image.png");
|
||||||
|
tags.put("twitter:card", "summary");
|
||||||
|
tags.put("twitter:title", title);
|
||||||
|
tags.put("twitter:description", description);
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> buildGenericMeta(String baseUrl) {
|
||||||
|
var tags = new LinkedHashMap<String, String>();
|
||||||
|
tags.put("og:title", GENERIC_TITLE);
|
||||||
|
tags.put("og:description", GENERIC_DESCRIPTION);
|
||||||
|
tags.put("og:url", baseUrl);
|
||||||
|
tags.put("og:type", "website");
|
||||||
|
tags.put("og:site_name", GENERIC_TITLE);
|
||||||
|
tags.put("og:image", baseUrl + "/og-image.png");
|
||||||
|
tags.put("twitter:card", "summary");
|
||||||
|
tags.put("twitter:title", GENERIC_TITLE);
|
||||||
|
tags.put("twitter:description", GENERIC_DESCRIPTION);
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> resolveEventMeta(String token, String baseUrl) {
|
||||||
|
try {
|
||||||
|
UUID uuid = UUID.fromString(token);
|
||||||
|
Optional<Event> event =
|
||||||
|
getEventUseCase.getByEventToken(new EventToken(uuid));
|
||||||
|
if (event.isPresent()) {
|
||||||
|
return buildEventMeta(event.get(), baseUrl);
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// Invalid UUID — fall back to generic
|
||||||
|
}
|
||||||
|
return buildGenericMeta(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Description formatting ---
|
||||||
|
|
||||||
|
private String truncateTitle(String title) {
|
||||||
|
if (title.length() <= MAX_TITLE_LENGTH) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
return title.substring(0, MAX_TITLE_LENGTH - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDescription(Event event) {
|
||||||
|
ZonedDateTime zoned = event.dateTime().atZoneSameInstant(event.timezone());
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
|
||||||
|
|
||||||
|
if (event.location() != null && !event.location().isBlank()) {
|
||||||
|
sb.append(" · 📍 ").append(event.location());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.description() != null && !event.description().isBlank()) {
|
||||||
|
sb.append(" — ").append(event.description());
|
||||||
|
}
|
||||||
|
|
||||||
|
String result = sb.toString();
|
||||||
|
if (result.length() > MAX_DESCRIPTION_LENGTH) {
|
||||||
|
return result.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML rendering ---
|
||||||
|
|
||||||
|
private String renderTags(Map<String, String> tags) {
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (var entry : tags.entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
String value = escapeHtml(entry.getValue());
|
||||||
|
String attr = key.startsWith("twitter:") ? "name" : "property";
|
||||||
|
sb.append("<meta ").append(attr).append("=\"").append(key)
|
||||||
|
.append("\" content=\"").append(value).append("\">\n");
|
||||||
|
}
|
||||||
|
return sb.toString().stripTrailing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeHtml(String input) {
|
||||||
|
return input
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getBaseUrl(HttpServletRequest request) {
|
||||||
|
return ServletUriComponentsBuilder.fromRequestUri(request)
|
||||||
|
.replacePath("")
|
||||||
|
.build()
|
||||||
|
.toUriString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,12 @@ public class EventJpaEntity {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
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. */
|
/** Returns the internal database ID. */
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
@@ -145,4 +151,24 @@ public class EventJpaEntity {
|
|||||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
this.createdAt = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ package de.fete.adapter.out.persistence;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
/** Spring Data JPA repository for event entities. */
|
/** Spring Data JPA repository for event entities. */
|
||||||
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
||||||
|
|
||||||
/** Finds an event by its public event token. */
|
/** Finds an event by its public event token. */
|
||||||
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
||||||
|
|
||||||
|
/** Deletes all events whose expiry date is before today. Returns the number of deleted rows. */
|
||||||
|
@Modifying
|
||||||
|
@Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)
|
||||||
|
int deleteExpired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package de.fete.adapter.out.persistence;
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
import de.fete.domain.model.Event;
|
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 de.fete.domain.port.out.EventRepository;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
/** Persistence adapter implementing the EventRepository outbound port. */
|
/** Persistence adapter implementing the EventRepository outbound port. */
|
||||||
@@ -26,37 +27,45 @@ public class EventPersistenceAdapter implements EventRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Event> findByEventToken(UUID eventToken) {
|
public Optional<Event> findByEventToken(EventToken eventToken) {
|
||||||
return jpaRepository.findByEventToken(eventToken).map(this::toDomain);
|
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int deleteExpired() {
|
||||||
|
return jpaRepository.deleteExpired();
|
||||||
}
|
}
|
||||||
|
|
||||||
private EventJpaEntity toEntity(Event event) {
|
private EventJpaEntity toEntity(Event event) {
|
||||||
var entity = new EventJpaEntity();
|
var entity = new EventJpaEntity();
|
||||||
entity.setId(event.getId());
|
entity.setId(event.id());
|
||||||
entity.setEventToken(event.getEventToken());
|
entity.setEventToken(event.eventToken().value());
|
||||||
entity.setOrganizerToken(event.getOrganizerToken());
|
entity.setOrganizerToken(event.organizerToken().value());
|
||||||
entity.setTitle(event.getTitle());
|
entity.setTitle(event.title());
|
||||||
entity.setDescription(event.getDescription());
|
entity.setDescription(event.description());
|
||||||
entity.setDateTime(event.getDateTime());
|
entity.setDateTime(event.dateTime());
|
||||||
entity.setTimezone(event.getTimezone().getId());
|
entity.setTimezone(event.timezone().getId());
|
||||||
entity.setLocation(event.getLocation());
|
entity.setLocation(event.location());
|
||||||
entity.setExpiryDate(event.getExpiryDate());
|
entity.setExpiryDate(event.expiryDate());
|
||||||
entity.setCreatedAt(event.getCreatedAt());
|
entity.setCreatedAt(event.createdAt());
|
||||||
|
entity.setCancelled(event.cancelled());
|
||||||
|
entity.setCancellationReason(event.cancellationReason());
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Event toDomain(EventJpaEntity entity) {
|
private Event toDomain(EventJpaEntity entity) {
|
||||||
var event = new Event();
|
return new Event(
|
||||||
event.setId(entity.getId());
|
entity.getId(),
|
||||||
event.setEventToken(entity.getEventToken());
|
new EventToken(entity.getEventToken()),
|
||||||
event.setOrganizerToken(entity.getOrganizerToken());
|
new OrganizerToken(entity.getOrganizerToken()),
|
||||||
event.setTitle(entity.getTitle());
|
entity.getTitle(),
|
||||||
event.setDescription(entity.getDescription());
|
entity.getDescription(),
|
||||||
event.setDateTime(entity.getDateTime());
|
entity.getDateTime(),
|
||||||
event.setTimezone(ZoneId.of(entity.getTimezone()));
|
ZoneId.of(entity.getTimezone()),
|
||||||
event.setLocation(entity.getLocation());
|
entity.getLocation(),
|
||||||
event.setExpiryDate(entity.getExpiryDate());
|
entity.getExpiryDate(),
|
||||||
event.setCreatedAt(entity.getCreatedAt());
|
entity.getCreatedAt(),
|
||||||
return event;
|
entity.isCancelled(),
|
||||||
|
entity.getCancellationReason());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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<RsvpJpaEntity, Long> {
|
||||||
|
|
||||||
|
/** Finds an RSVP by its token. */
|
||||||
|
java.util.Optional<RsvpJpaEntity> findByRsvpToken(UUID rsvpToken);
|
||||||
|
|
||||||
|
/** Counts RSVPs for the given event. */
|
||||||
|
long countByEventId(Long eventId);
|
||||||
|
|
||||||
|
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||||
|
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
|
||||||
|
|
||||||
|
/** Deletes an RSVP by event ID and RSVP token. Returns count of deleted rows. */
|
||||||
|
long deleteByEventIdAndRsvpToken(Long eventId, UUID rsvpToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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 java.util.List;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Rsvp> findByEventId(Long eventId) {
|
||||||
|
return jpaRepository.findAllByEventIdOrderByIdAsc(eventId).stream()
|
||||||
|
.map(this::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken) {
|
||||||
|
return jpaRepository.deleteByEventIdAndRsvpToken(eventId, rsvpToken.value()) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RsvpJpaEntity toEntity(Rsvp rsvp) {
|
||||||
|
var entity = new RsvpJpaEntity();
|
||||||
|
entity.setId(rsvp.id());
|
||||||
|
entity.setRsvpToken(rsvp.rsvpToken().value());
|
||||||
|
entity.setEventId(rsvp.eventId());
|
||||||
|
entity.setName(rsvp.name());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rsvp toDomain(RsvpJpaEntity entity) {
|
||||||
|
return new Rsvp(
|
||||||
|
entity.getId(),
|
||||||
|
new RsvpToken(entity.getRsvpToken()),
|
||||||
|
entity.getEventId(),
|
||||||
|
entity.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.application.service.exception.EventAlreadyCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
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.CreateEventUseCase;
|
||||||
import de.fete.domain.port.in.GetEventUseCase;
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
|
import de.fete.domain.port.in.UpdateEventUseCase;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/** Application service implementing event creation and retrieval. */
|
/** Application service implementing event creation and retrieval. */
|
||||||
@Service
|
@Service
|
||||||
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
public class EventService implements CreateEventUseCase, GetEventUseCase, UpdateEventUseCase {
|
||||||
|
|
||||||
|
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
|
||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
@@ -27,26 +35,50 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Event createEvent(CreateEventCommand command) {
|
public Event createEvent(CreateEventCommand command) {
|
||||||
if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
|
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
|
||||||
throw new ExpiryDateInPastException(command.expiryDate());
|
|
||||||
}
|
|
||||||
|
|
||||||
var event = new Event();
|
var event = new Event(
|
||||||
event.setEventToken(UUID.randomUUID());
|
null,
|
||||||
event.setOrganizerToken(UUID.randomUUID());
|
EventToken.generate(),
|
||||||
event.setTitle(command.title());
|
OrganizerToken.generate(),
|
||||||
event.setDescription(command.description());
|
command.title(),
|
||||||
event.setDateTime(command.dateTime());
|
command.description(),
|
||||||
event.setTimezone(command.timezone());
|
command.dateTime(),
|
||||||
event.setLocation(command.location());
|
command.timezone(),
|
||||||
event.setExpiryDate(command.expiryDate());
|
command.location(),
|
||||||
event.setCreatedAt(OffsetDateTime.now(clock));
|
expiryDate,
|
||||||
|
OffsetDateTime.now(clock),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
|
|
||||||
return eventRepository.save(event);
|
return eventRepository.save(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Event> getByEventToken(UUID eventToken) {
|
public Optional<Event> getByEventToken(EventToken eventToken) {
|
||||||
return eventRepository.findByEventToken(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.organizerToken().equals(organizerToken)) {
|
||||||
|
throw new InvalidOrganizerTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.cancelled()) {
|
||||||
|
throw new EventAlreadyCancelledException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
eventRepository.save(event.withCancellation(true, reason));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** Scheduled job that deletes events whose expiry date is in the past. */
|
||||||
|
@Component
|
||||||
|
public class ExpiredEventCleanupJob {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ExpiredEventCleanupJob.class);
|
||||||
|
|
||||||
|
private final EventRepository eventRepository;
|
||||||
|
|
||||||
|
/** Creates a new cleanup job with the given event repository. */
|
||||||
|
public ExpiredEventCleanupJob(EventRepository eventRepository) {
|
||||||
|
this.eventRepository = eventRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs daily at 03:00 and deletes all expired events. */
|
||||||
|
@Scheduled(cron = "0 0 3 * * *")
|
||||||
|
@Transactional
|
||||||
|
public void deleteExpiredEvents() {
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
log.info("Expired event cleanup: deleted {} event(s)", deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.application.service.exception.EventCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventExpiredException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
|
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.model.RsvpToken;
|
||||||
|
import de.fete.domain.port.in.CancelRsvpUseCase;
|
||||||
|
import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
||||||
|
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||||
|
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Application service implementing RSVP operations. */
|
||||||
|
@Service
|
||||||
|
public class RsvpService
|
||||||
|
implements CreateRsvpUseCase, CancelRsvpUseCase, CountAttendeesByEventUseCase,
|
||||||
|
GetAttendeesUseCase {
|
||||||
|
|
||||||
|
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()));
|
||||||
|
|
||||||
|
if (event.cancelled()) {
|
||||||
|
throw new EventCancelledException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.expiryDate().isAfter(LocalDate.now(clock))) {
|
||||||
|
throw new EventExpiredException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
var rsvp = new Rsvp(null, RsvpToken.generate(), event.id(), name.strip());
|
||||||
|
|
||||||
|
return rsvpRepository.save(rsvp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) {
|
||||||
|
eventRepository.findByEventToken(eventToken)
|
||||||
|
.ifPresent(event ->
|
||||||
|
rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long countByEvent(EventToken eventToken) {
|
||||||
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
return rsvpRepository.countByEventId(event.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
|
||||||
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
|
if (!event.organizerToken().equals(organizerToken)) {
|
||||||
|
throw new InvalidOrganizerTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rsvpRepository.findByEventId(event.id()).stream()
|
||||||
|
.map(Rsvp::name)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
|
/** Thrown when an invalid organizer token is provided. */
|
||||||
|
public class InvalidOrganizerTokenException extends RuntimeException {
|
||||||
|
|
||||||
|
/** Creates a new exception for an invalid organizer token. */
|
||||||
|
public InvalidOrganizerTokenException() {
|
||||||
|
super("Invalid organizer token.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service.exception;
|
||||||
|
|
||||||
/** Thrown when an invalid IANA timezone ID is provided. */
|
/** Thrown when an invalid IANA timezone ID is provided. */
|
||||||
public class InvalidTimezoneException extends RuntimeException {
|
public class InvalidTimezoneException extends RuntimeException {
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Application-layer exceptions thrown by service use case implementations.
|
||||||
|
*/
|
||||||
|
package de.fete.application.service.exception;
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
package de.fete.config;
|
package de.fete.config;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
|
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
|
||||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
|
||||||
|
|
||||||
/** Configures API path prefix and SPA static resource serving. */
|
/** Configures API path prefix. Static resources served by default Spring Boot handler. */
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebConfig implements WebMvcConfigurer {
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
/** Provides a system clock bean for time-dependent services. */
|
||||||
@Bean
|
@Bean
|
||||||
Clock clock() {
|
Clock clock() {
|
||||||
return Clock.systemDefaultZone();
|
return Clock.systemDefaultZone();
|
||||||
@@ -25,23 +21,4 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
public void configurePathMatch(PathMatchConfigurer configurer) {
|
public void configurePathMatch(PathMatchConfigurer configurer) {
|
||||||
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
|
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
|
||||||
registry.addResourceHandler("/**")
|
|
||||||
.addResourceLocations("classpath:/static/")
|
|
||||||
.resourceChain(true)
|
|
||||||
.addResolver(new PathResourceResolver() {
|
|
||||||
@Override
|
|
||||||
protected Resource getResource(String resourcePath,
|
|
||||||
Resource location) throws IOException {
|
|
||||||
Resource requested = location.createRelative(resourcePath);
|
|
||||||
if (requested.exists() && requested.isReadable()) {
|
|
||||||
return requested;
|
|
||||||
}
|
|
||||||
Resource index = new ClassPathResource("/static/index.html");
|
|
||||||
return (index.exists() && index.isReadable()) ? index : null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,5 @@ public record CreateEventCommand(
|
|||||||
String description,
|
String description,
|
||||||
OffsetDateTime dateTime,
|
OffsetDateTime dateTime,
|
||||||
ZoneId timezone,
|
ZoneId timezone,
|
||||||
String location,
|
String location
|
||||||
LocalDate expiryDate
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -3,119 +3,28 @@ package de.fete.domain.model;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/** Domain entity representing an event. */
|
/** Domain entity representing an event. */
|
||||||
public class Event {
|
public record Event(
|
||||||
|
Long id,
|
||||||
|
EventToken eventToken,
|
||||||
|
OrganizerToken organizerToken,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
OffsetDateTime dateTime,
|
||||||
|
ZoneId timezone,
|
||||||
|
String location,
|
||||||
|
LocalDate expiryDate,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
boolean cancelled,
|
||||||
|
String cancellationReason
|
||||||
|
) {
|
||||||
|
|
||||||
private Long id;
|
/** Returns a copy of this event with cancellation applied. */
|
||||||
private UUID eventToken;
|
public Event withCancellation(boolean cancelled, String cancellationReason) {
|
||||||
private UUID organizerToken;
|
return new Event(
|
||||||
private String title;
|
id, eventToken, organizerToken, title, description,
|
||||||
private String description;
|
dateTime, timezone, location, expiryDate, createdAt,
|
||||||
private OffsetDateTime dateTime;
|
cancelled, cancellationReason);
|
||||||
private ZoneId timezone;
|
|
||||||
private String location;
|
|
||||||
private LocalDate expiryDate;
|
|
||||||
private OffsetDateTime createdAt;
|
|
||||||
|
|
||||||
/** 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 public event token (UUID). */
|
|
||||||
public UUID getEventToken() {
|
|
||||||
return eventToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the public event token. */
|
|
||||||
public void setEventToken(UUID eventToken) {
|
|
||||||
this.eventToken = eventToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the secret organizer token (UUID). */
|
|
||||||
public UUID getOrganizerToken() {
|
|
||||||
return organizerToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the secret organizer token. */
|
|
||||||
public void setOrganizerToken(UUID organizerToken) {
|
|
||||||
this.organizerToken = organizerToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event title. */
|
|
||||||
public String getTitle() {
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event title. */
|
|
||||||
public void setTitle(String title) {
|
|
||||||
this.title = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event description. */
|
|
||||||
public String getDescription() {
|
|
||||||
return description;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event description. */
|
|
||||||
public void setDescription(String description) {
|
|
||||||
this.description = description;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event date and time with UTC offset. */
|
|
||||||
public OffsetDateTime getDateTime() {
|
|
||||||
return dateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event date and time. */
|
|
||||||
public void setDateTime(OffsetDateTime dateTime) {
|
|
||||||
this.dateTime = dateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the IANA timezone. */
|
|
||||||
public ZoneId getTimezone() {
|
|
||||||
return timezone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the IANA timezone. */
|
|
||||||
public void setTimezone(ZoneId timezone) {
|
|
||||||
this.timezone = timezone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the event location. */
|
|
||||||
public String getLocation() {
|
|
||||||
return location;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the event location. */
|
|
||||||
public void setLocation(String location) {
|
|
||||||
this.location = location;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the expiry date after which event data is deleted. */
|
|
||||||
public LocalDate getExpiryDate() {
|
|
||||||
return expiryDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the expiry date. */
|
|
||||||
public void setExpiryDate(LocalDate expiryDate) {
|
|
||||||
this.expiryDate = expiryDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the creation timestamp. */
|
|
||||||
public OffsetDateTime getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets the creation timestamp. */
|
|
||||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
|
||||||
this.createdAt = createdAt;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
backend/src/main/java/de/fete/domain/model/EventToken.java
Normal file
18
backend/src/main/java/de/fete/domain/model/EventToken.java
Normal file
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/main/java/de/fete/domain/model/Rsvp.java
Normal file
9
backend/src/main/java/de/fete/domain/model/Rsvp.java
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package de.fete.domain.model;
|
||||||
|
|
||||||
|
/** Domain entity representing an RSVP. */
|
||||||
|
public record Rsvp(
|
||||||
|
Long id,
|
||||||
|
RsvpToken rsvpToken,
|
||||||
|
Long eventId,
|
||||||
|
String name
|
||||||
|
) {}
|
||||||
18
backend/src/main/java/de/fete/domain/model/RsvpToken.java
Normal file
18
backend/src/main/java/de/fete/domain/model/RsvpToken.java
Normal file
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.RsvpToken;
|
||||||
|
|
||||||
|
/** Inbound port for cancelling an RSVP. */
|
||||||
|
public interface CancelRsvpUseCase {
|
||||||
|
|
||||||
|
/** Cancels the RSVP identified by the given tokens. Idempotent — no error if not found. */
|
||||||
|
void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Inbound port for retrieving attendee names of an event. */
|
||||||
|
public interface GetAttendeesUseCase {
|
||||||
|
|
||||||
|
/** Returns attendee names ordered by RSVP submission time. */
|
||||||
|
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package de.fete.domain.port.in;
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/** Inbound port for retrieving a public event by its token. */
|
/** Inbound port for retrieving a public event by its token. */
|
||||||
public interface GetEventUseCase {
|
public interface GetEventUseCase {
|
||||||
|
|
||||||
/** Finds an event by its public event token. */
|
/** Finds an event by its public event token. */
|
||||||
Optional<Event> getByEventToken(UUID eventToken);
|
Optional<Event> getByEventToken(EventToken eventToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package de.fete.domain.port.out;
|
package de.fete.domain.port.out;
|
||||||
|
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/** Outbound port for persisting and retrieving events. */
|
/** Outbound port for persisting and retrieving events. */
|
||||||
public interface EventRepository {
|
public interface EventRepository {
|
||||||
@@ -11,5 +11,8 @@ public interface EventRepository {
|
|||||||
Event save(Event event);
|
Event save(Event event);
|
||||||
|
|
||||||
/** Finds an event by its public event token. */
|
/** Finds an event by its public event token. */
|
||||||
Optional<Event> findByEventToken(UUID eventToken);
|
Optional<Event> findByEventToken(EventToken eventToken);
|
||||||
|
|
||||||
|
/** Deletes all events whose expiry date is in the past. Returns the number of deleted events. */
|
||||||
|
int deleteExpired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package de.fete.domain.port.out;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Rsvp;
|
||||||
|
import de.fete.domain.model.RsvpToken;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
|
||||||
|
/** Finds all RSVPs for the given event, ordered by ID ascending. */
|
||||||
|
List<Rsvp> findByEventId(Long eventId);
|
||||||
|
|
||||||
|
/** Deletes an RSVP by event ID and RSVP token. Returns true if a record was deleted. */
|
||||||
|
boolean deleteByEventIdAndRsvpToken(Long eventId, RsvpToken rsvpToken);
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ spring.jpa.open-in-view=false
|
|||||||
# Liquibase
|
# Liquibase
|
||||||
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
|
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
|
||||||
|
|
||||||
|
# Proxy headers
|
||||||
|
server.forward-headers-strategy=framework
|
||||||
|
|
||||||
# Actuator
|
# Actuator
|
||||||
management.endpoints.web.exposure.include=health
|
management.endpoints.web.exposure.include=health
|
||||||
management.endpoint.health.show-details=never
|
management.endpoint.health.show-details=never
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="003-create-rsvps-table" author="fete">
|
||||||
|
<createTable tableName="rsvps">
|
||||||
|
<column name="id" type="bigserial" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="rsvp_token" type="uuid">
|
||||||
|
<constraints nullable="false" unique="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="event_id" type="bigint">
|
||||||
|
<constraints nullable="false"
|
||||||
|
foreignKeyName="fk_rsvps_event_id"
|
||||||
|
references="events(id)"
|
||||||
|
deleteCascade="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="name" type="varchar(100)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<createIndex tableName="rsvps" indexName="idx_rsvps_event_id">
|
||||||
|
<column name="event_id"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<createIndex tableName="rsvps" indexName="idx_rsvps_rsvp_token">
|
||||||
|
<column name="rsvp_token"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="004-add-cancellation-columns" author="fete">
|
||||||
|
<addColumn tableName="events">
|
||||||
|
<column name="cancelled" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="cancellation_reason" type="VARCHAR(2000)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -8,5 +8,7 @@
|
|||||||
<include file="db/changelog/000-baseline.xml"/>
|
<include file="db/changelog/000-baseline.xml"/>
|
||||||
<include file="db/changelog/001-create-events-table.xml"/>
|
<include file="db/changelog/001-create-events-table.xml"/>
|
||||||
<include file="db/changelog/002-add-timezone-column.xml"/>
|
<include file="db/changelog/002-add-timezone-column.xml"/>
|
||||||
|
<include file="db/changelog/003-create-rsvps-table.xml"/>
|
||||||
|
<include file="db/changelog/004-add-cancellation-columns.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
@@ -37,14 +37,133 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ValidationProblemDetail"
|
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||||
|
|
||||||
/events/{token}:
|
/events/{eventToken}/rsvps/{rsvpToken}:
|
||||||
|
delete:
|
||||||
|
operationId: cancelRsvp
|
||||||
|
summary: Cancel RSVP
|
||||||
|
description: |
|
||||||
|
Permanently deletes an RSVP identified by the RSVP token.
|
||||||
|
Idempotent: returns 204 whether the RSVP existed or not.
|
||||||
|
tags:
|
||||||
|
- events
|
||||||
|
parameters:
|
||||||
|
- name: eventToken
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Event token (UUID)
|
||||||
|
- name: rsvpToken
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: RSVP token (UUID) identifying the attendance to cancel
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: >
|
||||||
|
RSVP successfully cancelled (or was already cancelled).
|
||||||
|
No response body.
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
|
||||||
|
/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"
|
||||||
|
|
||||||
|
/events/{eventToken}/attendees:
|
||||||
|
get:
|
||||||
|
operationId: getAttendees
|
||||||
|
summary: Get attendee list for an event (organizer only)
|
||||||
|
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
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Attendee list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GetAttendeesResponse"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
/events/{eventToken}:
|
||||||
get:
|
get:
|
||||||
operationId: getEvent
|
operationId: getEvent
|
||||||
summary: Get public event details by token
|
summary: Get public event details by token
|
||||||
tags:
|
tags:
|
||||||
- events
|
- events
|
||||||
parameters:
|
parameters:
|
||||||
- name: token
|
- name: eventToken
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
@@ -65,6 +184,58 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$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:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
CreateEventRequest:
|
CreateEventRequest:
|
||||||
@@ -73,7 +244,6 @@ components:
|
|||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- expiryDate
|
|
||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
@@ -94,11 +264,6 @@ components:
|
|||||||
location:
|
location:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 500
|
maxLength: 500
|
||||||
expiryDate:
|
|
||||||
type: string
|
|
||||||
format: date
|
|
||||||
description: Date after which event data is deleted. Must be in the future.
|
|
||||||
example: "2026-06-15"
|
|
||||||
|
|
||||||
CreateEventResponse:
|
CreateEventResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -108,7 +273,6 @@ components:
|
|||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- expiryDate
|
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
type: string
|
type: string
|
||||||
@@ -131,10 +295,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: IANA timezone of the organizer
|
description: IANA timezone of the organizer
|
||||||
example: "Europe/Berlin"
|
example: "Europe/Berlin"
|
||||||
expiryDate:
|
|
||||||
type: string
|
|
||||||
format: date
|
|
||||||
example: "2026-06-15"
|
|
||||||
|
|
||||||
GetEventResponse:
|
GetEventResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -144,7 +304,7 @@ components:
|
|||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- attendeeCount
|
- attendeeCount
|
||||||
- expired
|
- cancelled
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
type: string
|
type: string
|
||||||
@@ -177,10 +337,83 @@ components:
|
|||||||
minimum: 0
|
minimum: 0
|
||||||
description: Number of confirmed attendees (attending=true)
|
description: Number of confirmed attendees (attending=true)
|
||||||
example: 12
|
example: 12
|
||||||
expired:
|
cancelled:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the event's expiry date has passed
|
description: Whether the event has been cancelled
|
||||||
example: false
|
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
|
||||||
|
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"
|
||||||
|
|
||||||
|
GetAttendeesResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- attendees
|
||||||
|
properties:
|
||||||
|
attendees:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Attendee"
|
||||||
|
example:
|
||||||
|
- name: "Alice"
|
||||||
|
- name: "Bob"
|
||||||
|
|
||||||
|
Attendee:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 100
|
||||||
|
example: "Alice"
|
||||||
|
|
||||||
ProblemDetail:
|
ProblemDetail:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
|||||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||||
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
|
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.core.domain.JavaClass;
|
||||||
import com.tngtech.archunit.core.importer.ImportOption;
|
import com.tngtech.archunit.core.importer.ImportOption;
|
||||||
import com.tngtech.archunit.junit.AnalyzeClasses;
|
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||||
import com.tngtech.archunit.junit.ArchTest;
|
import com.tngtech.archunit.junit.ArchTest;
|
||||||
|
import com.tngtech.archunit.lang.ArchCondition;
|
||||||
import com.tngtech.archunit.lang.ArchRule;
|
import com.tngtech.archunit.lang.ArchRule;
|
||||||
|
import com.tngtech.archunit.lang.ConditionEvents;
|
||||||
|
import com.tngtech.archunit.lang.SimpleConditionEvent;
|
||||||
|
|
||||||
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
|
@AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class)
|
||||||
class HexagonalArchitectureTest {
|
class HexagonalArchitectureTest {
|
||||||
@@ -60,4 +64,29 @@ class HexagonalArchitectureTest {
|
|||||||
static final ArchRule persistenceMustNotDependOnWeb = noClasses()
|
static final ArchRule persistenceMustNotDependOnWeb = noClasses()
|
||||||
.that().resideInAPackage("de.fete.adapter.out.persistence..")
|
.that().resideInAPackage("de.fete.adapter.out.persistence..")
|
||||||
.should().dependOnClassesThat().resideInAPackage("de.fete.adapter.in.web..");
|
.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..");
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule domainModelsMustBeRecords = classes()
|
||||||
|
.that().resideInAPackage("de.fete.domain.model..")
|
||||||
|
.and().doNotHaveSimpleName("package-info")
|
||||||
|
.should(beRecords());
|
||||||
|
|
||||||
|
private static ArchCondition<JavaClass> beRecords() {
|
||||||
|
return new ArchCondition<>("be records") {
|
||||||
|
@Override
|
||||||
|
public void check(JavaClass javaClass,
|
||||||
|
ConditionEvents events) {
|
||||||
|
boolean isRecord = javaClass.reflect().isRecord();
|
||||||
|
if (!isRecord) {
|
||||||
|
events.add(SimpleConditionEvent.violated(javaClass,
|
||||||
|
javaClass.getFullName() + " is not a record"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.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.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
@@ -11,11 +13,16 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import de.fete.TestcontainersConfig;
|
import de.fete.TestcontainersConfig;
|
||||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
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.EventJpaEntity;
|
||||||
import de.fete.adapter.out.persistence.EventJpaRepository;
|
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.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -39,6 +46,9 @@ class EventControllerIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private EventJpaRepository jpaRepository;
|
private EventJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RsvpJpaRepository rsvpJpaRepository;
|
||||||
|
|
||||||
// --- Create Event tests ---
|
// --- Create Event tests ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -48,8 +58,7 @@ class EventControllerIntegrationTest {
|
|||||||
.description("Come celebrate!")
|
.description("Come celebrate!")
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("Europe/Berlin")
|
.timezone("Europe/Berlin")
|
||||||
.location("Berlin")
|
.location("Berlin");
|
||||||
.expiryDate(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
var result = mockMvc.perform(post("/api/events")
|
var result = mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -60,7 +69,6 @@ class EventControllerIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
||||||
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||||
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.expiryDate").isNotEmpty())
|
|
||||||
.andReturn();
|
.andReturn();
|
||||||
|
|
||||||
var response = objectMapper.readValue(
|
var response = objectMapper.readValue(
|
||||||
@@ -72,7 +80,7 @@ class EventControllerIntegrationTest {
|
|||||||
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
|
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
|
||||||
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
|
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
|
||||||
assertThat(persisted.getLocation()).isEqualTo("Berlin");
|
assertThat(persisted.getLocation()).isEqualTo("Berlin");
|
||||||
assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate());
|
assertThat(persisted.getExpiryDate()).isEqualTo(LocalDate.of(2026, 6, 22));
|
||||||
assertThat(persisted.getDateTime().toInstant())
|
assertThat(persisted.getDateTime().toInstant())
|
||||||
.isEqualTo(request.getDateTime().toInstant());
|
.isEqualTo(request.getDateTime().toInstant());
|
||||||
assertThat(persisted.getOrganizerToken()).isNotNull();
|
assertThat(persisted.getOrganizerToken()).isNotNull();
|
||||||
@@ -84,8 +92,7 @@ class EventControllerIntegrationTest {
|
|||||||
var request = new CreateEventRequest()
|
var request = new CreateEventRequest()
|
||||||
.title("Minimal Event")
|
.title("Minimal Event")
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("UTC")
|
.timezone("UTC");
|
||||||
.expiryDate(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
var result = mockMvc.perform(post("/api/events")
|
var result = mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -108,39 +115,9 @@ class EventControllerIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventMissingTitleReturns400() throws Exception {
|
void createEventMissingTitleReturns400() throws Exception {
|
||||||
|
long countBefore = jpaRepository.count();
|
||||||
|
|
||||||
var request = new CreateEventRequest()
|
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));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
|
||||||
.andExpect(jsonPath("$.title").value("Validation Failed"))
|
|
||||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createEventMissingDateTimeReturns400() throws Exception {
|
|
||||||
var request = new CreateEventRequest()
|
|
||||||
.title("No Date")
|
|
||||||
.timezone("Europe/Berlin")
|
|
||||||
.expiryDate(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
|
||||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createEventMissingExpiryDateReturns400() throws Exception {
|
|
||||||
var request = new CreateEventRequest()
|
|
||||||
.title("No Expiry")
|
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("Europe/Berlin");
|
.timezone("Europe/Berlin");
|
||||||
|
|
||||||
@@ -149,39 +126,28 @@ class EventControllerIntegrationTest {
|
|||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.title").value("Validation Failed"))
|
||||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
|
||||||
|
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventExpiryDateInPastReturns400() throws Exception {
|
void createEventMissingDateTimeReturns400() throws Exception {
|
||||||
|
long countBefore = jpaRepository.count();
|
||||||
|
|
||||||
var request = new CreateEventRequest()
|
var request = new CreateEventRequest()
|
||||||
.title("Past Expiry")
|
.title("No Date")
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.timezone("Europe/Berlin");
|
||||||
.timezone("Europe/Berlin")
|
|
||||||
.expiryDate(LocalDate.of(2025, 1, 1));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||||
void createEventExpiryDateTodayReturns400() throws Exception {
|
|
||||||
var request = new CreateEventRequest()
|
|
||||||
.title("Today Expiry")
|
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
|
||||||
.timezone("Europe/Berlin")
|
|
||||||
.expiryDate(LocalDate.now());
|
|
||||||
|
|
||||||
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-in-past"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -189,8 +155,7 @@ class EventControllerIntegrationTest {
|
|||||||
var request = new CreateEventRequest()
|
var request = new CreateEventRequest()
|
||||||
.title("")
|
.title("")
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("Europe/Berlin")
|
.timezone("Europe/Berlin");
|
||||||
.expiryDate(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -201,11 +166,12 @@ class EventControllerIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEventWithInvalidTimezoneReturns400() throws Exception {
|
void createEventWithInvalidTimezoneReturns400() throws Exception {
|
||||||
|
long countBefore = jpaRepository.count();
|
||||||
|
|
||||||
var request = new CreateEventRequest()
|
var request = new CreateEventRequest()
|
||||||
.title("Bad TZ")
|
.title("Bad TZ")
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("Not/A/Zone")
|
.timezone("Not/A/Zone");
|
||||||
.expiryDate(LocalDate.now().plusDays(30));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -213,6 +179,8 @@ class EventControllerIntegrationTest {
|
|||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-timezone"));
|
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-timezone"));
|
||||||
|
|
||||||
|
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GET /events/{token} tests ---
|
// --- GET /events/{token} tests ---
|
||||||
@@ -231,7 +199,6 @@ class EventControllerIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||||
.andExpect(jsonPath("$.location").value("Central Park"))
|
.andExpect(jsonPath("$.location").value("Central Park"))
|
||||||
.andExpect(jsonPath("$.attendeeCount").value(0))
|
.andExpect(jsonPath("$.attendeeCount").value(0))
|
||||||
.andExpect(jsonPath("$.expired").value(false))
|
|
||||||
.andExpect(jsonPath("$.dateTime").isNotEmpty());
|
.andExpect(jsonPath("$.dateTime").isNotEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,16 +223,373 @@ class EventControllerIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
// --- RSVP tests ---
|
||||||
void getExpiredEventReturnsExpiredTrue() throws Exception {
|
|
||||||
EventJpaEntity entity = seedEvent(
|
|
||||||
"Past Event", "It happened", "Europe/Berlin",
|
|
||||||
"Old Venue", LocalDate.now().minusDays(1));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
|
@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));
|
||||||
|
long countBefore = rsvpJpaRepository.count();
|
||||||
|
|
||||||
|
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"));
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
long countBefore = rsvpJpaRepository.count();
|
||||||
|
|
||||||
|
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"));
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpForExpiredEventReturns409() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Expired Party", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().minusDays(1));
|
||||||
|
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-expired"));
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GET /events/{token}/attendees tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturnsNamesForOrganizer() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
seedRsvp(event, "Alice");
|
||||||
|
seedRsvp(event, "Bob");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.title").value("Past Event"))
|
.andExpect(jsonPath("$.attendees").isArray())
|
||||||
.andExpect(jsonPath("$.expired").value(true));
|
.andExpect(jsonPath("$.attendees.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
|
||||||
|
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Empty Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.attendees").isArray())
|
||||||
|
.andExpect(jsonPath("$.attendees.length()").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Secret Party", null, "Europe/Berlin", null,
|
||||||
|
LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()
|
||||||
|
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(
|
||||||
|
"application/problem+json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeesReturns404ForUnknownEvent() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
|
||||||
|
+ "/attendees?organizerToken=" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(
|
||||||
|
"application/problem+json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cancel RSVP tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpReturns204AndDeletesRow() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Cancel Event", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().plusDays(30));
|
||||||
|
UUID rsvpToken = seedRsvpAndGetToken(event, "Departing Guest");
|
||||||
|
|
||||||
|
long countBefore = rsvpJpaRepository.count();
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||||
|
+ "/rsvps/" + rsvpToken))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore - 1);
|
||||||
|
assertThat(rsvpJpaRepository.findByRsvpToken(rsvpToken)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpReturns204WhenAlreadyDeleted() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Idempotent Event", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||||
|
+ "/rsvps/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpReturns204WhenEventNotFound() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/events/" + UUID.randomUUID()
|
||||||
|
+ "/rsvps/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attendeeCountDecreasesAfterCancelRsvp() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Count Cancel Event", null, "Europe/Berlin",
|
||||||
|
null, LocalDate.now().plusDays(30));
|
||||||
|
UUID rsvpToken = seedRsvpAndGetToken(event, "Leaving Guest");
|
||||||
|
seedRsvp(event, "Staying Guest");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.andExpect(jsonPath("$.attendeeCount").value(2));
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/events/" + event.getEventToken()
|
||||||
|
+ "/rsvps/" + rsvpToken))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||||
|
.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();
|
||||||
|
rsvp.setRsvpToken(token);
|
||||||
|
rsvp.setEventId(event.getId());
|
||||||
|
rsvp.setName(name);
|
||||||
|
rsvpJpaRepository.save(rsvp);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedRsvp(EventJpaEntity event, String name) {
|
||||||
|
var rsvp = new RsvpJpaEntity();
|
||||||
|
rsvp.setRsvpToken(UUID.randomUUID());
|
||||||
|
rsvp.setEventId(event.getId());
|
||||||
|
rsvp.setName(name);
|
||||||
|
rsvpJpaRepository.save(rsvp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private EventJpaEntity seedEvent(
|
private EventJpaEntity seedEvent(
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import de.fete.TestcontainersConfig;
|
||||||
|
import de.fete.adapter.out.persistence.EventJpaEntity;
|
||||||
|
import de.fete.adapter.out.persistence.EventJpaRepository;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
|
class SpaControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EventJpaRepository eventJpaRepository;
|
||||||
|
|
||||||
|
// --- Phase 2: Base functionality ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rootServesHtml() throws Exception {
|
||||||
|
mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rootHtmlDoesNotContainPlaceholder() throws Exception {
|
||||||
|
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).doesNotContain("<!-- OG_META_TAGS -->");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRouteServesHtml() throws Exception {
|
||||||
|
mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventsRouteServesHtml() throws Exception {
|
||||||
|
mockMvc.perform(get("/events").accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 4 (US2): Generic OG meta-tags ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rootContainsGenericOgTitle() throws Exception {
|
||||||
|
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("og:title");
|
||||||
|
assertThat(html).contains("content=\"fete\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRouteContainsGenericOgDescription() throws Exception {
|
||||||
|
String html = mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("og:description");
|
||||||
|
assertThat(html).contains("Privacy-focused event planning");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownRouteReturns404() throws Exception {
|
||||||
|
mockMvc.perform(get("/unknown/path").accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 5 (US3): Twitter Card meta-tags ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventRouteContainsTwitterCardTags() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Twitter Test", "Testing cards",
|
||||||
|
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("twitter:card");
|
||||||
|
assertThat(html).contains("twitter:title");
|
||||||
|
assertThat(html).contains("twitter:description");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void genericRouteContainsTwitterCardTags() throws Exception {
|
||||||
|
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("twitter:card");
|
||||||
|
assertThat(html).contains("content=\"summary\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 3 (US1): Event-specific OG meta-tags ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventRouteContainsEventSpecificOgTitle() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Birthday Party", "Come celebrate!",
|
||||||
|
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("og:title");
|
||||||
|
assertThat(html).contains("Birthday Party");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventRouteContainsOgDescription() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"BBQ", "Bring drinks!",
|
||||||
|
"Europe/Berlin", "Central Park", LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("og:description");
|
||||||
|
assertThat(html).contains("Central Park");
|
||||||
|
assertThat(html).contains("Bring drinks!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventRouteContainsOgUrl() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Party", null,
|
||||||
|
"Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("og:url");
|
||||||
|
assertThat(html).contains("/events/" + event.getEventToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventRouteContainsOgImage() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Party", null,
|
||||||
|
"Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("og:image");
|
||||||
|
assertThat(html).contains("/og-image.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownEventTokenFallsBackToGenericMeta() throws Exception {
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + UUID.randomUUID()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("og:title");
|
||||||
|
assertThat(html).contains("content=\"fete\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML escaping ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void specialCharactersAreHtmlEscaped() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Tom & Jerry's \"Party\"", "Fun <times> & more",
|
||||||
|
"Europe/Berlin", "O'Brien's Pub", LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("Tom & Jerry");
|
||||||
|
assertThat(html).contains("& more");
|
||||||
|
assertThat(html).contains("<times>");
|
||||||
|
assertThat(html).doesNotContain("content=\"Tom & Jerry");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Title truncation ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void longTitleIsTruncatedTo70Chars() throws Exception {
|
||||||
|
String longTitle = "A".repeat(80);
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
longTitle, "Desc",
|
||||||
|
"Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("A".repeat(67) + "...");
|
||||||
|
assertThat(html).doesNotContain("A".repeat(68));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Description formatting ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventWithoutLocationOmitsPinEmoji() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Online Meetup", "Virtual gathering",
|
||||||
|
"Europe/Berlin", null, LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).doesNotContain("📍");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventWithoutDescriptionOmitsDash() throws Exception {
|
||||||
|
EventJpaEntity event = seedEvent(
|
||||||
|
"Silent Event", null,
|
||||||
|
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
String html = mockMvc.perform(
|
||||||
|
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(html).contains("📅");
|
||||||
|
assertThat(html).contains("Berlin");
|
||||||
|
assertThat(html).doesNotContain(" — ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventJpaEntity seedEvent(
|
||||||
|
String title, String description, String timezone,
|
||||||
|
String location, LocalDate expiryDate) {
|
||||||
|
var entity = new EventJpaEntity();
|
||||||
|
entity.setEventToken(UUID.randomUUID());
|
||||||
|
entity.setOrganizerToken(UUID.randomUUID());
|
||||||
|
entity.setTitle(title);
|
||||||
|
entity.setDescription(description);
|
||||||
|
entity.setDateTime(
|
||||||
|
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||||
|
entity.setTimezone(timezone);
|
||||||
|
entity.setLocation(location);
|
||||||
|
entity.setExpiryDate(expiryDate);
|
||||||
|
entity.setCreatedAt(OffsetDateTime.now());
|
||||||
|
return eventJpaRepository.save(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
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 org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
|
@Transactional
|
||||||
|
class EventPersistenceAdapterIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EventRepository eventRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredRemovesExpiredEvents() {
|
||||||
|
Event expired = buildEvent("Expired Party", LocalDate.now().minusDays(1));
|
||||||
|
eventRepository.save(expired);
|
||||||
|
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(deleted).isGreaterThanOrEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredKeepsNonExpiredEvents() {
|
||||||
|
Event future = buildEvent("Future Party", LocalDate.now().plusDays(30));
|
||||||
|
Event saved = eventRepository.save(future);
|
||||||
|
|
||||||
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredKeepsEventsExpiringToday() {
|
||||||
|
Event today = buildEvent("Today Party", LocalDate.now());
|
||||||
|
Event saved = eventRepository.save(today);
|
||||||
|
|
||||||
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredReturnsZeroWhenNoneExpired() {
|
||||||
|
// Only save a future event
|
||||||
|
buildEvent("Future Only", LocalDate.now().plusDays(60));
|
||||||
|
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(deleted).isGreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Event buildEvent(String title, LocalDate expiryDate) {
|
||||||
|
return new Event(
|
||||||
|
null,
|
||||||
|
EventToken.generate(),
|
||||||
|
OrganizerToken.generate(),
|
||||||
|
title,
|
||||||
|
"Test description",
|
||||||
|
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||||
|
ZoneId.of("Europe/Berlin"),
|
||||||
|
"Test Location",
|
||||||
|
expiryDate,
|
||||||
|
OffsetDateTime.now(),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
import de.fete.TestcontainersConfig;
|
import de.fete.TestcontainersConfig;
|
||||||
import de.fete.domain.model.Event;
|
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 de.fete.domain.port.out.EventRepository;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
@@ -29,8 +30,8 @@ class EventPersistenceAdapterTest {
|
|||||||
|
|
||||||
Event saved = eventRepository.save(event);
|
Event saved = eventRepository.save(event);
|
||||||
|
|
||||||
assertThat(saved.getId()).isNotNull();
|
assertThat(saved.id()).isNotNull();
|
||||||
assertThat(saved.getTitle()).isEqualTo("Test Event");
|
assertThat(saved.title()).isEqualTo("Test Event");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -38,16 +39,16 @@ class EventPersistenceAdapterTest {
|
|||||||
Event event = buildEvent();
|
Event event = buildEvent();
|
||||||
Event saved = eventRepository.save(event);
|
Event saved = eventRepository.save(event);
|
||||||
|
|
||||||
Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
|
Optional<Event> found = eventRepository.findByEventToken(saved.eventToken());
|
||||||
|
|
||||||
assertThat(found).isPresent();
|
assertThat(found).isPresent();
|
||||||
assertThat(found.get().getTitle()).isEqualTo("Test Event");
|
assertThat(found.get().title()).isEqualTo("Test Event");
|
||||||
assertThat(found.get().getId()).isEqualTo(saved.getId());
|
assertThat(found.get().id()).isEqualTo(saved.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findByUnknownEventTokenReturnsEmpty() {
|
void findByUnknownEventTokenReturnsEmpty() {
|
||||||
Optional<Event> found = eventRepository.findByEventToken(UUID.randomUUID());
|
Optional<Event> found = eventRepository.findByEventToken(EventToken.generate());
|
||||||
|
|
||||||
assertThat(found).isEmpty();
|
assertThat(found).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -60,42 +61,47 @@ class EventPersistenceAdapterTest {
|
|||||||
OffsetDateTime createdAt =
|
OffsetDateTime createdAt =
|
||||||
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
||||||
|
|
||||||
var event = new Event();
|
var event = new Event(
|
||||||
event.setEventToken(UUID.randomUUID());
|
null,
|
||||||
event.setOrganizerToken(UUID.randomUUID());
|
EventToken.generate(),
|
||||||
event.setTitle("Full Event");
|
OrganizerToken.generate(),
|
||||||
event.setDescription("A detailed description");
|
"Full Event",
|
||||||
event.setDateTime(dateTime);
|
"A detailed description",
|
||||||
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
dateTime,
|
||||||
event.setLocation("Berlin, Germany");
|
ZoneId.of("Europe/Berlin"),
|
||||||
event.setExpiryDate(expiryDate);
|
"Berlin, Germany",
|
||||||
event.setCreatedAt(createdAt);
|
expiryDate,
|
||||||
|
createdAt,
|
||||||
|
false,
|
||||||
|
null);
|
||||||
|
|
||||||
Event saved = eventRepository.save(event);
|
Event saved = eventRepository.save(event);
|
||||||
Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
|
Event found = eventRepository.findByEventToken(saved.eventToken()).orElseThrow();
|
||||||
|
|
||||||
assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
|
assertThat(found.eventToken()).isEqualTo(event.eventToken());
|
||||||
assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
|
assertThat(found.organizerToken()).isEqualTo(event.organizerToken());
|
||||||
assertThat(found.getTitle()).isEqualTo("Full Event");
|
assertThat(found.title()).isEqualTo("Full Event");
|
||||||
assertThat(found.getDescription()).isEqualTo("A detailed description");
|
assertThat(found.description()).isEqualTo("A detailed description");
|
||||||
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
assertThat(found.dateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
||||||
assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
assertThat(found.timezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
||||||
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
|
assertThat(found.location()).isEqualTo("Berlin, Germany");
|
||||||
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
|
assertThat(found.expiryDate()).isEqualTo(expiryDate);
|
||||||
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
|
assertThat(found.createdAt().toInstant()).isEqualTo(createdAt.toInstant());
|
||||||
}
|
}
|
||||||
|
|
||||||
private Event buildEvent() {
|
private Event buildEvent() {
|
||||||
var event = new Event();
|
return new Event(
|
||||||
event.setEventToken(UUID.randomUUID());
|
null,
|
||||||
event.setOrganizerToken(UUID.randomUUID());
|
EventToken.generate(),
|
||||||
event.setTitle("Test Event");
|
OrganizerToken.generate(),
|
||||||
event.setDescription("Test description");
|
"Test Event",
|
||||||
event.setDateTime(OffsetDateTime.now().plusDays(7));
|
"Test description",
|
||||||
event.setTimezone(ZoneId.of("Europe/Berlin"));
|
OffsetDateTime.now().plusDays(7),
|
||||||
event.setLocation("Somewhere");
|
ZoneId.of("Europe/Berlin"),
|
||||||
event.setExpiryDate(LocalDate.now().plusDays(30));
|
"Somewhere",
|
||||||
event.setCreatedAt(OffsetDateTime.now());
|
LocalDate.now().plusDays(30),
|
||||||
return event;
|
OffsetDateTime.now(),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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.exception.EventAlreadyCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
|
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(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||||
|
null, false, null);
|
||||||
|
|
||||||
|
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<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||||
|
verify(eventRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().cancelled()).isTrue();
|
||||||
|
assertThat(captor.getValue().cancellationReason()).isEqualTo("Venue unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelEventWithNullReason() {
|
||||||
|
EventToken eventToken = EventToken.generate();
|
||||||
|
OrganizerToken organizerToken = OrganizerToken.generate();
|
||||||
|
var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||||
|
null, false, null);
|
||||||
|
|
||||||
|
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<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||||
|
verify(eventRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().cancelled()).isTrue();
|
||||||
|
assertThat(captor.getValue().cancellationReason()).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(null, eventToken, correctToken, null, null, null, null, null, null,
|
||||||
|
null, false, null);
|
||||||
|
|
||||||
|
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(null, eventToken, organizerToken, null, null, null, null, null, null,
|
||||||
|
null, true, null);
|
||||||
|
|
||||||
|
when(eventRepository.findByEventToken(eventToken))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null))
|
||||||
|
.isInstanceOf(EventAlreadyCancelledException.class);
|
||||||
|
|
||||||
|
verify(eventRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -9,15 +8,14 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
import de.fete.domain.model.CreateEventCommand;
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
import de.fete.domain.model.Event;
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -32,6 +30,7 @@ class EventServiceTest {
|
|||||||
private static final Instant FIXED_INSTANT =
|
private static final Instant FIXED_INSTANT =
|
||||||
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
|
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
|
||||||
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
|
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
|
||||||
|
private static final LocalDate TODAY = LocalDate.ofInstant(FIXED_INSTANT, ZONE);
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private EventRepository eventRepository;
|
private EventRepository eventRepository;
|
||||||
@@ -51,21 +50,20 @@ class EventServiceTest {
|
|||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Birthday Party",
|
"Birthday Party",
|
||||||
"Come celebrate!",
|
"Come celebrate!",
|
||||||
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||||
ZoneId.of("Europe/Berlin"),
|
ZONE,
|
||||||
"Berlin",
|
"Berlin"
|
||||||
LocalDate.of(2026, 7, 15)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getTitle()).isEqualTo("Birthday Party");
|
assertThat(result.title()).isEqualTo("Birthday Party");
|
||||||
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
assertThat(result.description()).isEqualTo("Come celebrate!");
|
||||||
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
assertThat(result.timezone()).isEqualTo(ZONE);
|
||||||
assertThat(result.getLocation()).isEqualTo("Berlin");
|
assertThat(result.location()).isEqualTo("Berlin");
|
||||||
assertThat(result.getEventToken()).isNotNull();
|
assertThat(result.eventToken()).isNotNull();
|
||||||
assertThat(result.getOrganizerToken()).isNotNull();
|
assertThat(result.organizerToken()).isNotNull();
|
||||||
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK));
|
assertThat(result.createdAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -75,77 +73,51 @@ class EventServiceTest {
|
|||||||
|
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
|
||||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
eventService.createEvent(command);
|
eventService.createEvent(command);
|
||||||
|
|
||||||
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||||
verify(eventRepository, times(1)).save(captor.capture());
|
verify(eventRepository, times(1)).save(captor.capture());
|
||||||
assertThat(captor.getValue().getTitle()).isEqualTo("Test");
|
assertThat(captor.getValue().title()).isEqualTo("Test");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void expiryDateTodayThrowsException() {
|
void expiryDateIsEventDatePlusSevenDays() {
|
||||||
var command = new CreateEventCommand(
|
|
||||||
"Test", null,
|
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
|
||||||
LocalDate.now(FIXED_CLOCK)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
|
||||||
.isInstanceOf(ExpiryDateInPastException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void expiryDateInPastThrowsException() {
|
|
||||||
var command = new CreateEventCommand(
|
|
||||||
"Test", null,
|
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
|
||||||
LocalDate.now(FIXED_CLOCK).minusDays(5)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
|
||||||
.isInstanceOf(ExpiryDateInPastException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void expiryDateTomorrowSucceeds() {
|
|
||||||
when(eventRepository.save(any(Event.class)))
|
when(eventRepository.save(any(Event.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
var eventDate = TODAY.plusDays(10);
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
eventDate.atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
|
||||||
LocalDate.now(FIXED_CLOCK).plusDays(1)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6));
|
assertThat(result.expiryDate()).isEqualTo(eventDate.plusDays(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GetEventUseCase tests (T004) ---
|
// --- GetEventUseCase tests (T004) ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getByEventTokenReturnsEvent() {
|
void getByEventTokenReturnsEvent() {
|
||||||
UUID token = UUID.randomUUID();
|
EventToken token = EventToken.generate();
|
||||||
var event = new Event();
|
var event = new Event(null, token, null, "Found Event", null, null, null, null, null, null,
|
||||||
event.setEventToken(token);
|
false, null);
|
||||||
event.setTitle("Found Event");
|
|
||||||
when(eventRepository.findByEventToken(token))
|
when(eventRepository.findByEventToken(token))
|
||||||
.thenReturn(Optional.of(event));
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
Optional<Event> result = eventService.getByEventToken(token);
|
Optional<Event> result = eventService.getByEventToken(token);
|
||||||
|
|
||||||
assertThat(result).isPresent();
|
assertThat(result).isPresent();
|
||||||
assertThat(result.get().getTitle()).isEqualTo("Found Event");
|
assertThat(result.get().title()).isEqualTo("Found Event");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getByEventTokenReturnsEmptyForUnknownToken() {
|
void getByEventTokenReturnsEmptyForUnknownToken() {
|
||||||
UUID token = UUID.randomUUID();
|
EventToken token = EventToken.generate();
|
||||||
when(eventRepository.findByEventToken(token))
|
when(eventRepository.findByEventToken(token))
|
||||||
.thenReturn(Optional.empty());
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
@@ -163,13 +135,12 @@ class EventServiceTest {
|
|||||||
|
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1),
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||||
ZoneId.of("America/New_York"), null,
|
ZoneId.of("America/New_York"), null
|
||||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
|
assertThat(result.timezone()).isEqualTo(ZoneId.of("America/New_York"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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.application.service.exception.EventCancelledException;
|
||||||
|
import de.fete.application.service.exception.EventExpiredException;
|
||||||
|
import de.fete.application.service.exception.EventNotFoundException;
|
||||||
|
import de.fete.application.service.exception.InvalidOrganizerTokenException;
|
||||||
|
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.model.RsvpToken;
|
||||||
|
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.List;
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private RsvpRepository rsvpRepository;
|
||||||
|
|
||||||
|
private RsvpService rsvpService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpSucceedsForActiveEvent() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
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.name()).isEqualTo("Max Mustermann");
|
||||||
|
assertThat(result.rsvpToken()).isNotNull();
|
||||||
|
assertThat(result.eventId()).isEqualTo(event.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpPersistsViaRepository() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
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<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
|
||||||
|
verify(rsvpRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().name()).isEqualTo("Test Guest");
|
||||||
|
assertThat(captor.getValue().eventId()).isEqualTo(event.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
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.name()).isEqualTo("Max");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpThrowsWhenEventExpired() {
|
||||||
|
Event event = buildActiveEvent(TODAY.minusDays(1));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||||
|
.isInstanceOf(EventExpiredException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createRsvpThrowsWhenEventExpiresToday() {
|
||||||
|
Event event = buildActiveEvent(TODAY);
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
|
||||||
|
.isInstanceOf(EventExpiredException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesReturnsNamesInOrder() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
OrganizerToken orgToken = event.organizerToken();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.findByEventId(event.id()))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
buildRsvp(1L, "Alice"),
|
||||||
|
buildRsvp(2L, "Bob"),
|
||||||
|
buildRsvp(3L, "Charlie")));
|
||||||
|
|
||||||
|
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||||
|
|
||||||
|
assertThat(names).containsExactly("Alice", "Bob", "Charlie");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
OrganizerToken orgToken = event.organizerToken();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.findByEventId(event.id()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
|
||||||
|
|
||||||
|
assertThat(names).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesThrowsWhenEventNotFound() {
|
||||||
|
EventToken token = EventToken.generate();
|
||||||
|
OrganizerToken orgToken = OrganizerToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> rsvpService.getAttendeeNames(token, orgToken))
|
||||||
|
.isInstanceOf(EventNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
OrganizerToken wrongToken = OrganizerToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token))
|
||||||
|
.thenReturn(Optional.of(event));
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> rsvpService.getAttendeeNames(token, wrongToken))
|
||||||
|
.isInstanceOf(InvalidOrganizerTokenException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rsvp buildRsvp(Long id, String name) {
|
||||||
|
return new Rsvp(id, RsvpToken.generate(), 1L, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpDeletesWhenEventAndRsvpExist() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
RsvpToken rsvpToken = RsvpToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(true);
|
||||||
|
|
||||||
|
rsvpService.cancelRsvp(token, rsvpToken);
|
||||||
|
|
||||||
|
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpSucceedsWhenRsvpNotFound() {
|
||||||
|
Event event = buildActiveEvent(TODAY.plusDays(30));
|
||||||
|
EventToken token = event.eventToken();
|
||||||
|
RsvpToken rsvpToken = RsvpToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||||
|
when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(false);
|
||||||
|
|
||||||
|
rsvpService.cancelRsvp(token, rsvpToken);
|
||||||
|
|
||||||
|
verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelRsvpSucceedsWhenEventNotFound() {
|
||||||
|
EventToken token = EventToken.generate();
|
||||||
|
RsvpToken rsvpToken = RsvpToken.generate();
|
||||||
|
when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
rsvpService.cancelRsvp(token, rsvpToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Event buildActiveEvent(LocalDate expiryDate) {
|
||||||
|
return new Event(
|
||||||
|
1L,
|
||||||
|
EventToken.generate(),
|
||||||
|
OrganizerToken.generate(),
|
||||||
|
"Test Event",
|
||||||
|
null,
|
||||||
|
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||||
|
ZONE,
|
||||||
|
null,
|
||||||
|
expiryDate,
|
||||||
|
OffsetDateTime.now(),
|
||||||
|
false,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,10 @@ class WebConfigTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void apiPrefixNotAccessibleWithoutIt() throws Exception {
|
void apiPrefixNotAccessibleWithoutIt() throws Exception {
|
||||||
// /events without /api prefix should not resolve to the API endpoint
|
// /events without /api prefix should not resolve to the REST API endpoint;
|
||||||
mockMvc.perform(get("/events"))
|
// it is served by SpaController as HTML instead
|
||||||
.andExpect(status().isNotFound());
|
mockMvc.perform(get("/events")
|
||||||
|
.accept("text/html"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
backend/src/test/resources/static/index.html
Normal file
13
backend/src/test/resources/static/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<!-- OG_META_TAGS -->
|
||||||
|
<title>fete</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
docs/screenshots/01-create-event.png
Normal file
BIN
docs/screenshots/01-create-event.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
BIN
docs/screenshots/02-event-detail.png
Normal file
BIN
docs/screenshots/02-event-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
BIN
docs/screenshots/03-rsvp.png
Normal file
BIN
docs/screenshots/03-rsvp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const organizerEvent: StoredEvent = {
|
||||||
|
eventToken: 'org-event-aaa',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
dateTime: '2027-06-15T18:00:00Z',
|
||||||
|
organizerToken: 'org-secret-token',
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendeeEvent: StoredEvent = {
|
||||||
|
eventToken: 'att-event-bbb',
|
||||||
|
title: 'Team Meeting',
|
||||||
|
dateTime: '2027-01-10T09:00:00Z',
|
||||||
|
rsvpToken: 'rsvp-token-1',
|
||||||
|
rsvpName: 'Alice',
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US1: Organizer Cancels Event from List', () => {
|
||||||
|
test('T001: organizer taps delete, confirms, event is removed after successful API call', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent, attendeeEvent]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
let patchCalled = false
|
||||||
|
network.use(
|
||||||
|
http.patch('*/api/events/:token', ({ request, params }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (
|
||||||
|
params['token'] === organizerEvent.eventToken &&
|
||||||
|
url.searchParams.get('organizerToken') === organizerEvent.organizerToken
|
||||||
|
) {
|
||||||
|
patchCalled = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'about:blank', title: 'Forbidden', status: 403 },
|
||||||
|
{ status: 403, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
|
||||||
|
// Click delete on organizer event
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog appears with organizer-specific text
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
// Confirm cancellation
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event is removed from list
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
// Other event remains
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
|
||||||
|
expect(patchCalled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T002: organizer confirms cancellation, API fails, event stays in list and error shown', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.patch('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Internal Server Error',
|
||||||
|
status: 500,
|
||||||
|
},
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event stays in list
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T003: organizer confirms cancellation, API returns 409 Conflict, event is silently removed', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.patch('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Conflict',
|
||||||
|
status: 409,
|
||||||
|
detail: 'Event is already cancelled.',
|
||||||
|
},
|
||||||
|
{ status: 409, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// 409 treated as success — event removed
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T004: organizer opens cancel dialog then dismisses (cancel button), event remains', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
// Dismiss via Cancel button
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T004b: organizer opens cancel dialog then dismisses via Escape', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
|
||||||
|
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T004c: organizer opens cancel dialog then dismisses via overlay click', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||||
|
|
||||||
|
// Click on overlay (outside dialog)
|
||||||
|
await page.locator('.confirm-dialog__overlay').click({ position: { x: 10, y: 10 } })
|
||||||
|
|
||||||
|
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US2: Distinct Dialog for Organizer vs. Attendee', () => {
|
||||||
|
test('T011: organizer dialog shows event-cancellation warning', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([organizerEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
const dialog = page.getByRole('alertdialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// Organizer-specific title and message
|
||||||
|
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Cancel event?')
|
||||||
|
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||||
|
'all attendees',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('T012: attendee dialog preserves existing RSVP-cancellation message', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([attendeeEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Team Meeting/ }).click()
|
||||||
|
const dialog = page.getByRole('alertdialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// Attendee-specific title and message
|
||||||
|
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Remove event?')
|
||||||
|
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||||
|
'attendance will be cancelled',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
166
frontend/e2e/cancel-event.spec.ts
Normal file
166
frontend/e2e/cancel-event.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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}`)
|
||||||
|
|
||||||
|
// Open kebab menu, then cancel event
|
||||||
|
const kebabBtn = page.getByRole('button', { name: /Event actions/i })
|
||||||
|
await expect(kebabBtn).toBeVisible()
|
||||||
|
await kebabBtn.click()
|
||||||
|
|
||||||
|
const cancelItem = page.getByRole('menuitem', { name: /Cancel event/i })
|
||||||
|
await expect(cancelItem).toBeVisible()
|
||||||
|
await cancelItem.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()
|
||||||
|
|
||||||
|
// Kebab menu should be gone (event is cancelled)
|
||||||
|
await expect(kebabBtn).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: /Event actions/i }).click()
|
||||||
|
await page.getByRole('menuitem', { 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: /Event actions/i }).click()
|
||||||
|
await page.getByRole('menuitem', { 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
276
frontend/e2e/cancel-rsvp.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsvpSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
rsvpToken,
|
||||||
|
rsvpName: 'Anna',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US1: Cancel RSVP from Event Detail View', () => {
|
||||||
|
test('status bar shows cancel affordance when RSVP\'d', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Status bar visible
|
||||||
|
const statusBar = page.getByRole('button', { name: /You're attending/ })
|
||||||
|
await expect(statusBar).toBeVisible()
|
||||||
|
|
||||||
|
// Cancel button hidden initially
|
||||||
|
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tapping status bar reveals cancel button', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Tap status bar
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
|
||||||
|
// Cancel button appears
|
||||||
|
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirm cancellation → localStorage cleared, count decremented, bar reset', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Expand → Cancel RSVP → Confirm in dialog
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
await page.locator('.rsvp-bar__cancel').click()
|
||||||
|
|
||||||
|
// Confirm dialog
|
||||||
|
await expect(page.getByText('The organizer will no longer see you as attending.')).toBeVisible()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||||
|
|
||||||
|
// Bar resets to CTA state
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||||
|
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||||
|
|
||||||
|
// Attendee count decremented
|
||||||
|
await expect(page.getByText('11 going')).toBeVisible()
|
||||||
|
|
||||||
|
// localStorage cleared
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('fete:events')
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||||
|
expect(event?.rsvpToken).toBeUndefined()
|
||||||
|
expect(event?.rsvpName).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('server error → error message, state unchanged', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Expand → Cancel → Confirm in dialog
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
await page.locator('.rsvp-bar__cancel').click()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
|
||||||
|
|
||||||
|
// Attendee count unchanged
|
||||||
|
await expect(page.getByText('12 going')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('re-RSVP after cancel works', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
http.post('*/api/events/:token/rsvps', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Cancel first
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
await page.locator('.rsvp-bar__cancel').click()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||||
|
|
||||||
|
// CTA should be back
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||||
|
|
||||||
|
// Re-RSVP
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Status bar returns
|
||||||
|
await expect(page.getByText("You're attending!")).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US2: Auto-Cancel on Event List Removal', () => {
|
||||||
|
test('removal of RSVP\'d event shows attendance warning in dialog', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removal of non-RSVP\'d watcher event shows standard dialog', async ({ page }) => {
|
||||||
|
const watcherEvent: StoredEvent = {
|
||||||
|
eventToken: 'watcher-token',
|
||||||
|
title: 'Watcher Event',
|
||||||
|
dateTime: '2027-06-15T18:00:00Z',
|
||||||
|
}
|
||||||
|
await page.addInitScript(seedEvents([watcherEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Watcher events are removed directly without dialog
|
||||||
|
await page.getByRole('button', { name: /Remove Watcher Event/ }).click()
|
||||||
|
|
||||||
|
// Watcher removal is immediate — event disappears
|
||||||
|
await expect(page.getByText('Watcher Event')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirm removal → DELETE called → event removed from list', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event gone
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
|
||||||
|
// localStorage updated
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('fete:events')
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
const found = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||||
|
expect(found).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('server error on DELETE → error message, event stays in list', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return HttpResponse.json({ error: 'fail' }, { status: 500 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event still in list
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dismiss dialog → no changes', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
|
// Event still there
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
|
||||||
|
test('cancel from detail view with stale token (404) → treated as success', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Cancel flow
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
await page.locator('.rsvp-bar__cancel').click()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||||
|
|
||||||
|
// Treated as success — CTA returns
|
||||||
|
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
|
||||||
|
|
||||||
|
// localStorage cleaned
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('fete:events')
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
|
||||||
|
expect(event?.rsvpToken).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('event list removal with stale token (404) → treated as success', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return HttpResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event removed from list
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
frontend/e2e/cancelled-event-visitor.spec.ts
Normal file
74
frontend/e2e/cancelled-event-visitor.spec.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,7 +9,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
|
|
||||||
await expect(page.getByText('Title is required.')).toBeVisible()
|
await expect(page.getByText('Title is required.')).toBeVisible()
|
||||||
await expect(page.getByText('Date and time are required.')).toBeVisible()
|
await expect(page.getByText('Date and time are required.')).toBeVisible()
|
||||||
await expect(page.getByText('Expiry date is required.')).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('creates an event and redirects to event detail page', async ({ page }) => {
|
test('creates an event and redirects to event detail page', async ({ page }) => {
|
||||||
@@ -19,7 +18,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
await page.getByLabel(/description/i).fill('Bring your own drinks')
|
await page.getByLabel(/description/i).fill('Bring your own drinks')
|
||||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||||
await page.getByLabel(/location/i).fill('Central Park')
|
await page.getByLabel(/location/i).fill('Central Park')
|
||||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /create event/i }).click()
|
await page.getByRole('button', { name: /create event/i }).click()
|
||||||
|
|
||||||
@@ -31,7 +29,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
|
|
||||||
await page.getByLabel(/title/i).fill('Summer BBQ')
|
await page.getByLabel(/title/i).fill('Summer BBQ')
|
||||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /create event/i }).click()
|
await page.getByRole('button', { name: /create event/i }).click()
|
||||||
await expect(page).toHaveURL(/\/events\/.+/)
|
await expect(page).toHaveURL(/\/events\/.+/)
|
||||||
@@ -59,7 +56,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
await page.goto('/create')
|
await page.goto('/create')
|
||||||
await page.getByLabel(/title/i).fill('Test')
|
await page.getByLabel(/title/i).fill('Test')
|
||||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /create event/i }).click()
|
await page.getByRole('button', { name: /create event/i }).click()
|
||||||
|
|
||||||
|
|||||||
172
frontend/e2e/event-rsvp.spec.ts
Normal file
172
frontend/e2e/event-rsvp.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -9,7 +9,6 @@ const fullEvent = {
|
|||||||
timezone: 'Europe/Berlin',
|
timezone: 'Europe/Berlin',
|
||||||
location: 'Central Park, NYC',
|
location: 'Central Park, NYC',
|
||||||
attendeeCount: 12,
|
attendeeCount: 12,
|
||||||
expired: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('US-1: View event details', () => {
|
test.describe('US-1: View event details', () => {
|
||||||
@@ -52,20 +51,6 @@ test.describe('US-1: View event details', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('US-2: View expired event', () => {
|
|
||||||
test('shows "event has ended" banner for 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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe('US-4: Event not found', () => {
|
test.describe('US-4: Event not found', () => {
|
||||||
test('shows "event not found" for unknown token', async ({ page, network }) => {
|
test('shows "event not found" for unknown token', async ({ page, network }) => {
|
||||||
network.use(
|
network.use(
|
||||||
|
|||||||
382
frontend/e2e/home-events.spec.ts
Normal file
382
frontend/e2e/home-events.spec.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
const futureEvent1: StoredEvent = {
|
||||||
|
eventToken: 'future-aaa',
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
dateTime: '2027-06-15T18:00:00Z',
|
||||||
|
organizerToken: 'org-token-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const futureEvent2: StoredEvent = {
|
||||||
|
eventToken: 'future-bbb',
|
||||||
|
title: 'Team Meeting',
|
||||||
|
dateTime: '2027-01-10T09:00:00Z',
|
||||||
|
rsvpToken: 'rsvp-token-1',
|
||||||
|
rsvpName: 'Alice',
|
||||||
|
}
|
||||||
|
|
||||||
|
const pastEvent: StoredEvent = {
|
||||||
|
eventToken: 'past-ccc',
|
||||||
|
title: 'New Year Party',
|
||||||
|
dateTime: '2025-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US2: Empty State', () => {
|
||||||
|
test('shows empty state when no events are stored', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('No events yet')).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: /Create Event/ })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty state links to create page', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const link = page.getByRole('link', { name: /Create Event/ })
|
||||||
|
await expect(link).toHaveAttribute('href', '/create')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty state is hidden when events exist', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('No events yet')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US4: Past Events Appear Faded', () => {
|
||||||
|
test('past events have the faded modifier class', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const cards = page.locator('.event-card')
|
||||||
|
await expect(cards).toHaveCount(2)
|
||||||
|
|
||||||
|
// Future event should NOT have past class
|
||||||
|
const futureCard = cards.filter({ hasText: 'Summer BBQ' })
|
||||||
|
await expect(futureCard).not.toHaveClass(/event-card--past/)
|
||||||
|
|
||||||
|
// Past event should have past class
|
||||||
|
const pastCard = cards.filter({ hasText: 'New Year Party' })
|
||||||
|
await expect(pastCard).toHaveClass(/event-card--past/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('past events remain clickable', async ({ page, network }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
eventToken: pastEvent.eventToken,
|
||||||
|
title: pastEvent.title,
|
||||||
|
dateTime: pastEvent.dateTime,
|
||||||
|
description: '',
|
||||||
|
location: '',
|
||||||
|
timezone: 'UTC',
|
||||||
|
attendeeCount: 0,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByText('New Year Party').click()
|
||||||
|
await expect(page).toHaveURL(`/events/${pastEvent.eventToken}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US3: Remove Event from List', () => {
|
||||||
|
test('delete icon triggers confirmation dialog, confirm removes event', async ({
|
||||||
|
page,
|
||||||
|
network,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
||||||
|
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.patch('*/api/events/:token', () => {
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Both events visible
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
|
||||||
|
// Click delete on Summer BBQ (organizer event)
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog appears (organizer event shows "Cancel event?")
|
||||||
|
await expect(page.getByText('Cancel event?')).toBeVisible()
|
||||||
|
|
||||||
|
// Confirm removal
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||||
|
|
||||||
|
// Event is gone, other remains
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel keeps the event in the list', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
await expect(page.getByText('Cancel event?')).toBeVisible()
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
|
// Dialog gone, event still there
|
||||||
|
await expect(page.getByText('Cancel event?')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US5: Visual Distinction for Event Roles', () => {
|
||||||
|
test('shows organizer badge for events with organizerToken', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const card = page.locator('.event-card').filter({ hasText: 'Summer BBQ' })
|
||||||
|
const badge = card.locator('.event-card__badge')
|
||||||
|
await expect(badge).toBeVisible()
|
||||||
|
await expect(badge).toHaveText('Organizing')
|
||||||
|
await expect(badge).toHaveClass(/event-card__badge--organizer/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows attendee badge for events with rsvpToken only', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent2]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const card = page.locator('.event-card').filter({ hasText: 'Team Meeting' })
|
||||||
|
const badge = card.locator('.event-card__badge')
|
||||||
|
await expect(badge).toBeVisible()
|
||||||
|
await expect(badge).toHaveText('Attending')
|
||||||
|
await expect(badge).toHaveClass(/event-card__badge--attendee/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows watcher badge for events without organizerToken or rsvpToken', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const card = page.locator('.event-card').filter({ hasText: 'New Year Party' })
|
||||||
|
const badge = card.locator('.event-card__badge')
|
||||||
|
await expect(badge).toBeVisible()
|
||||||
|
await expect(badge).toHaveText('Watching')
|
||||||
|
await expect(badge).toHaveClass(/event-card__badge--watcher/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('FAB: Create Event Button', () => {
|
||||||
|
test('FAB is visible when events exist', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const fab = page.getByRole('link', { name: 'Create event' })
|
||||||
|
await expect(fab).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('FAB navigates to create page', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const fab = page.getByRole('link', { name: 'Create event' })
|
||||||
|
await expect(fab).toHaveAttribute('href', '/create')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('FAB is not visible on empty state (empty state has its own CTA)', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.locator('.fab')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Temporal Grouping: Section Headers', () => {
|
||||||
|
test('events are distributed under correct section headers', async ({ page }) => {
|
||||||
|
// Use dates relative to "now" to ensure correct section assignment
|
||||||
|
const now = new Date()
|
||||||
|
const todayEvent: StoredEvent = {
|
||||||
|
eventToken: 'today-1',
|
||||||
|
title: 'Today Standup',
|
||||||
|
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
|
||||||
|
}
|
||||||
|
const laterEvent: StoredEvent = {
|
||||||
|
eventToken: 'later-1',
|
||||||
|
title: 'Future Conference',
|
||||||
|
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
|
||||||
|
}
|
||||||
|
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Verify section headers appear
|
||||||
|
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
|
||||||
|
|
||||||
|
// Events are in the correct sections
|
||||||
|
const sections = page.locator('.event-section')
|
||||||
|
const todaySection = sections.filter({ has: page.getByRole('heading', { name: 'Today', level: 2 }) })
|
||||||
|
await expect(todaySection.getByText('Today Standup')).toBeVisible()
|
||||||
|
|
||||||
|
const laterSection = sections.filter({ has: page.getByRole('heading', { name: 'Later', level: 2 }) })
|
||||||
|
await expect(laterSection.getByText('Future Conference')).toBeVisible()
|
||||||
|
|
||||||
|
const pastSection = sections.filter({ has: page.getByRole('heading', { name: 'Past', level: 2 }) })
|
||||||
|
await expect(pastSection.getByText('New Year Party')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty sections are not rendered', async ({ page }) => {
|
||||||
|
// Only a past event — no Today, This Week, or Later sections
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Past', level: 2 })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Today', level: 2 })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('heading', { name: 'This Week', level: 2 })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Next Week', level: 2 })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Later', level: 2 })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Today section header has emphasis CSS class', async ({ page }) => {
|
||||||
|
const now = new Date()
|
||||||
|
const todayEvent: StoredEvent = {
|
||||||
|
eventToken: 'today-emph',
|
||||||
|
title: 'Emphasis Test',
|
||||||
|
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
|
||||||
|
}
|
||||||
|
await page.addInitScript(seedEvents([todayEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const todayHeader = page.getByRole('heading', { name: 'Today', level: 2 })
|
||||||
|
await expect(todayHeader).toHaveClass(/section-header--emphasized/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Temporal Grouping: Date Subheaders', () => {
|
||||||
|
test('no date subheader in Today section', async ({ page }) => {
|
||||||
|
const now = new Date()
|
||||||
|
const todayEvent: StoredEvent = {
|
||||||
|
eventToken: 'today-sub',
|
||||||
|
title: 'No Subheader Test',
|
||||||
|
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
|
||||||
|
}
|
||||||
|
await page.addInitScript(seedEvents([todayEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const todaySection = page.locator('.event-section').filter({
|
||||||
|
has: page.getByRole('heading', { name: 'Today', level: 2 }),
|
||||||
|
})
|
||||||
|
await expect(todaySection.locator('.date-subheader')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('date subheaders appear in Later section', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const laterSection = page.locator('.event-section').filter({
|
||||||
|
has: page.getByRole('heading', { name: 'Later', level: 2 }),
|
||||||
|
})
|
||||||
|
// Both future events are on different dates, so expect subheaders
|
||||||
|
const subheaders = laterSection.locator('.date-subheader')
|
||||||
|
await expect(subheaders).toHaveCount(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('date subheaders appear in Past section', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const pastSection = page.locator('.event-section').filter({
|
||||||
|
has: page.getByRole('heading', { name: 'Past', level: 2 }),
|
||||||
|
})
|
||||||
|
await expect(pastSection.locator('.date-subheader')).toHaveCount(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Temporal Grouping: Time Display', () => {
|
||||||
|
test('future event cards show clock time', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const timeLabel = page.locator('.event-card__time')
|
||||||
|
const text = await timeLabel.first().textContent()
|
||||||
|
// Should show clock time (e.g., "18:00" or "6:00 PM"), not relative time
|
||||||
|
expect(text).toMatch(/\d{1,2}[:.]\d{2}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('past event cards show relative time', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const timeLabel = page.locator('.event-card__time')
|
||||||
|
const text = await timeLabel.first().textContent()
|
||||||
|
// Should show relative time like "X years ago" or "last year"
|
||||||
|
expect(text).toMatch(/ago|last|yesterday/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US1: View My Events', () => {
|
||||||
|
test('displays all stored events with title and relative time', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||||
|
await expect(page.getByText('New Year Party')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('events are sorted: upcoming ascending, then past', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1, futureEvent2, pastEvent]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const titles = page.locator('.event-card__title')
|
||||||
|
await expect(titles).toHaveCount(3)
|
||||||
|
// Team Meeting (Jan 2027) before Summer BBQ (Jun 2027), then past event
|
||||||
|
await expect(titles.nth(0)).toHaveText('Team Meeting')
|
||||||
|
await expect(titles.nth(1)).toHaveText('Summer BBQ')
|
||||||
|
await expect(titles.nth(2)).toHaveText('New Year Party')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking an event navigates to its detail page', async ({ page, network }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
|
||||||
|
// Mock the event detail API so navigation doesn't fail
|
||||||
|
const { http, HttpResponse } = await import('msw')
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
eventToken: futureEvent1.eventToken,
|
||||||
|
title: futureEvent1.title,
|
||||||
|
dateTime: futureEvent1.dateTime,
|
||||||
|
description: '',
|
||||||
|
location: '',
|
||||||
|
timezone: 'UTC',
|
||||||
|
attendeeCount: 0,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByText('Summer BBQ').click()
|
||||||
|
await expect(page).toHaveURL(`/events/${futureEvent1.eventToken}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('each event shows a relative time label', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([futureEvent1]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// The relative time element should exist and contain text (exact value depends on current time)
|
||||||
|
const timeLabel = page.locator('.event-card__time')
|
||||||
|
await expect(timeLabel).toHaveCount(1)
|
||||||
|
await expect(timeLabel.first()).not.toBeEmpty()
|
||||||
|
})
|
||||||
|
})
|
||||||
108
frontend/e2e/ical-download.spec.ts
Normal file
108
frontend/e2e/ical-download.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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: 'Sommerfest am See',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-07-15T18:00:00+02:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Stadtpark Berlin',
|
||||||
|
attendeeCount: 12,
|
||||||
|
cancelled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelledEvent = {
|
||||||
|
...fullEvent,
|
||||||
|
cancelled: true,
|
||||||
|
cancellationReason: 'Bad weather',
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsvpSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012',
|
||||||
|
rsvpName: 'Anna',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function organizerSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
organizerToken: 'org-token-1234',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('iCal download: calendar button visibility', () => {
|
||||||
|
test('calendar button visible for pre-RSVP visitor', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||||
|
await expect(calendarBtn).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calendar button visible for post-RSVP attendee', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||||
|
await expect(calendarBtn).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calendar button visible for organizer', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.get('*/api/events/:token/attendees*', () =>
|
||||||
|
HttpResponse.json({ attendees: [] }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||||
|
await expect(calendarBtn).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calendar button NOT visible for cancelled event', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(cancelledEvent)),
|
||||||
|
)
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
const calendarBtn = page.getByRole('button', { name: /add to calendar/i })
|
||||||
|
await expect(calendarBtn).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('iCal download: file generation', () => {
|
||||||
|
test('triggers download with correct filename', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Intercept the download by overriding the click-link mechanism
|
||||||
|
const downloadPromise = page.waitForEvent('download')
|
||||||
|
await page.getByRole('button', { name: /add to calendar/i }).click()
|
||||||
|
const download = await downloadPromise
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toBe('sommerfest-am-see.ics')
|
||||||
|
})
|
||||||
|
})
|
||||||
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
99
frontend/e2e/view-attendee-list.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
|
||||||
|
const eventToken = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||||
|
const organizerToken = 'f9e8d7c6-b5a4-3210-fedc-ba9876543210'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken,
|
||||||
|
title: 'Summer BBQ',
|
||||||
|
description: 'Bring your own drinks!',
|
||||||
|
dateTime: '2026-03-15T20:00:00+01:00',
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
location: 'Central Park, NYC',
|
||||||
|
attendeeCount: 3,
|
||||||
|
expired: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendeesResponse = {
|
||||||
|
attendees: [
|
||||||
|
{ name: 'Alice' },
|
||||||
|
{ name: 'Bob' },
|
||||||
|
{ name: 'Charlie' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US-1: View attendee list as organizer', () => {
|
||||||
|
test('organizer sees attendee names', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
http.get('*/api/events/:token/attendees', () => {
|
||||||
|
return HttpResponse.json(attendeesResponse)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set organizer token in localStorage before navigating
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate(
|
||||||
|
([et, ot]) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'fete:events',
|
||||||
|
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[eventToken, organizerToken],
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('3 Attendees')).toBeVisible()
|
||||||
|
await expect(page.getByText('Alice')).toBeVisible()
|
||||||
|
await expect(page.getByText('Bob')).toBeVisible()
|
||||||
|
await expect(page.getByText('Charlie')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('visitor does not see attendee list', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('3 going')).toBeVisible()
|
||||||
|
await expect(page.locator('.attendee-list')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('organizer sees empty state when no attendees', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({ ...fullEvent, attendeeCount: 0 })
|
||||||
|
}),
|
||||||
|
http.get('*/api/events/:token/attendees', () => {
|
||||||
|
return HttpResponse.json({ attendees: [] })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate(
|
||||||
|
([et, ot]) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'fete:events',
|
||||||
|
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[eventToken, organizerToken],
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('0 Attendees')).toBeVisible()
|
||||||
|
await expect(page.getByText('No attendees yet.')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
218
frontend/e2e/watch-event.spec.ts
Normal file
218
frontend/e2e/watch-event.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
|
||||||
|
const organizerToken = 'org-token-1234'
|
||||||
|
|
||||||
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsvpSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
rsvpToken,
|
||||||
|
rsvpName: 'Anna',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function organizerSeed(): StoredEvent {
|
||||||
|
return {
|
||||||
|
eventToken: fullEvent.eventToken,
|
||||||
|
title: fullEvent.title,
|
||||||
|
dateTime: fullEvent.dateTime,
|
||||||
|
organizerToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US1: Watch event from detail page', () => {
|
||||||
|
test('bookmark unfilled by default, tapping watches the event', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||||
|
await expect(bookmark).toBeVisible()
|
||||||
|
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
|
||||||
|
|
||||||
|
await bookmark.click()
|
||||||
|
|
||||||
|
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||||
|
|
||||||
|
// Navigate to event list via back link
|
||||||
|
await page.getByLabel('Back to home').click()
|
||||||
|
|
||||||
|
// Event appears with "Watching" label
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
await expect(page.getByText('Watching')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US2: Un-watch event from detail page', () => {
|
||||||
|
test('tapping filled bookmark un-watches the event', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([watchSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||||
|
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||||
|
|
||||||
|
await bookmark.click()
|
||||||
|
|
||||||
|
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
|
||||||
|
|
||||||
|
// Navigate to event list via back link (avoid page.goto re-running addInitScript)
|
||||||
|
await page.getByLabel('Back to home').click()
|
||||||
|
|
||||||
|
// Event is gone
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US3: Bookmark reflects attending status', () => {
|
||||||
|
test('bookmark is not visible when user has RSVPed, list shows Attendee', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Bookmark not shown for attendees — RsvpBar shows status state
|
||||||
|
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||||
|
await expect(bookmark).not.toBeVisible()
|
||||||
|
|
||||||
|
// Navigate to list via back link
|
||||||
|
await page.getByLabel('Back to home').click()
|
||||||
|
await expect(page.getByText('Attending')).toBeVisible()
|
||||||
|
await expect(page.getByText('Watching')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US4: RSVP cancellation preserves watch status', () => {
|
||||||
|
test('cancel RSVP → bookmark reappears, list shows Watching', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Cancel RSVP
|
||||||
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
||||||
|
await page.locator('.rsvp-bar__cancel').click()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
|
||||||
|
|
||||||
|
// Bookmark reappears in CTA state, filled because event is still stored
|
||||||
|
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||||
|
await expect(bookmark).toBeVisible()
|
||||||
|
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||||
|
|
||||||
|
// Navigate to list via back link
|
||||||
|
await page.getByLabel('Back to home').click()
|
||||||
|
await expect(page.getByText('Watching')).toBeVisible()
|
||||||
|
await expect(page.getByText('Attending')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US5: No bookmark for attendees and organizers', () => {
|
||||||
|
test('attendee does not see bookmark', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||||
|
await expect(bookmark).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('organizer does not see bookmark', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([organizerSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||||
|
await expect(bookmark).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US6: Un-watch from event list', () => {
|
||||||
|
test('deleting a watched event skips confirmation dialog', async ({ page }) => {
|
||||||
|
await page.addInitScript(seedEvents([watchSeed()]))
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||||
|
|
||||||
|
// No confirmation dialog — event removed immediately
|
||||||
|
await expect(page.getByText('Remove event?')).not.toBeVisible()
|
||||||
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US7: Watcher upgrades to attendee', () => {
|
||||||
|
test('watch → RSVP → bookmark disappears, list shows Attendee', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
||||||
|
http.post('*/api/events/:token/rsvps', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(seedEvents([watchSeed()]))
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
// Verify watching state — bookmark visible
|
||||||
|
const bookmark = page.getByLabel(/watch.*this event/i)
|
||||||
|
await expect(bookmark).toBeVisible()
|
||||||
|
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
||||||
|
|
||||||
|
// RSVP
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Bookmark gone — status bar shown instead
|
||||||
|
await expect(bookmark).not.toBeVisible()
|
||||||
|
|
||||||
|
// Navigate to list via back link
|
||||||
|
await page.getByLabel('Back to home').click()
|
||||||
|
await expect(page.getByText('Attending')).toBeVisible()
|
||||||
|
await expect(page.getByText('Watching')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<!-- OG_META_TAGS -->
|
||||||
<title>fete</title>
|
<title>fete</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
1761
frontend/package-lock.json
generated
1761
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,21 +38,27 @@
|
|||||||
"@vue/tsconfig": "^0.9.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.51.0",
|
"eslint-plugin-oxlint": "~1.55.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"msw": "^2.12.10",
|
"msw": "^2.12.10",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"oxlint": "~1.51.0",
|
"oxlint": "~1.55.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-vue-devtools": "^8.0.6",
|
"vite-plugin-vue-devtools": "^8.0.6",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"vue-tsc": "^3.2.5"
|
"vue-tsc": "^3.2.5"
|
||||||
},
|
},
|
||||||
|
"browserslist": [
|
||||||
|
">= 0.5%",
|
||||||
|
"last 2 versions",
|
||||||
|
"Firefox ESR",
|
||||||
|
"not dead"
|
||||||
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
3
frontend/public/favicon.svg
Normal file
3
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<text y="0.9em" font-size="80" x="50%" text-anchor="middle">🎉</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 144 B |
BIN
frontend/public/og-image.png
Normal file
BIN
frontend/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -1,9 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
|
<header v-if="route.name !== 'home'" class="app-header">
|
||||||
|
<BackLink />
|
||||||
|
<div id="header-actions"></div>
|
||||||
|
</header>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
|
import BackLink from '@/components/BackLink.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-lg) var(--content-padding);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header :deep(*) {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
BIN
frontend/src/assets/images/event-hero-placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -16,6 +16,37 @@
|
|||||||
--color-text-on-gradient: #ffffff;
|
--color-text-on-gradient: #ffffff;
|
||||||
--color-surface: #fff5f8;
|
--color-surface: #fff5f8;
|
||||||
--color-card: #ffffff;
|
--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);
|
||||||
|
--color-danger-solid: #d32f2f;
|
||||||
|
--color-danger-solid-hover: #b71c1c;
|
||||||
|
--color-danger-solid-text: #fff;
|
||||||
|
|
||||||
|
/* Glass system */
|
||||||
|
--color-glass: rgba(255, 255, 255, 0.1);
|
||||||
|
--color-glass-strong: rgba(255, 255, 255, 0.15);
|
||||||
|
--color-glass-subtle: rgba(255, 255, 255, 0.05);
|
||||||
|
--color-glass-border: rgba(255, 255, 255, 0.18);
|
||||||
|
--color-glass-border-hover: rgba(255, 255, 255, 0.3);
|
||||||
|
--color-glass-hover: rgba(255, 255, 255, 0.18);
|
||||||
|
--color-glass-inner: rgba(27, 23, 48, 0.55);
|
||||||
|
--color-glass-overlay: rgba(27, 23, 48, 0.4);
|
||||||
|
|
||||||
|
/* Text on gradient (opacity variants) */
|
||||||
|
--color-text-muted: rgba(255, 255, 255, 0.5);
|
||||||
|
--color-text-secondary: rgba(255, 255, 255, 0.7);
|
||||||
|
--color-text-soft: rgba(255, 255, 255, 0.85);
|
||||||
|
--color-text-bright: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
/* Glow border */
|
||||||
|
--gradient-glow: conic-gradient(from 135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
|
||||||
|
|
||||||
/* Gradient */
|
/* Gradient */
|
||||||
--gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
|
--gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
|
||||||
@@ -33,7 +64,7 @@
|
|||||||
--radius-button: 14px;
|
--radius-button: 14px;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.12);
|
||||||
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
|
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
@@ -60,7 +91,22 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--gradient-primary);
|
background-color: var(--color-dark-base);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--color-dark-base);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 70% 20%, rgba(240, 98, 146, 0.55) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 25% 50%, rgba(171, 71, 188, 0.5) 0px, transparent 55%),
|
||||||
|
radial-gradient(at 80% 70%, rgba(92, 107, 192, 0.55) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 35% 85%, rgba(255, 112, 67, 0.3) 0px, transparent 40%);
|
||||||
|
filter: blur(80px);
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
@@ -82,38 +128,66 @@ body {
|
|||||||
/* Card-style form fields */
|
/* Card-style form fields */
|
||||||
.form-field {
|
.form-field {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border: none;
|
border: 1px solid #e0e0e0;
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
padding: var(--spacing-md) var(--spacing-md);
|
padding: var(--spacing-md) var(--spacing-md);
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: box-shadow 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field.glass {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field:focus {
|
.form-field:focus {
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
border-color: var(--color-glass-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field::placeholder {
|
.form-field::placeholder {
|
||||||
color: #999;
|
color: var(--color-text-muted);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-field.glass::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
textarea.form-field {
|
textarea.form-field {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 5rem;
|
min-height: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* iOS Safari: datetime-local overflows container and shows empty when no value */
|
||||||
|
input[type="datetime-local"].form-field {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="datetime-local"].form-field.glass::-webkit-date-and-time-value {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="datetime-local"].form-field.glass::-webkit-datetime-edit {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="datetime-local"].form-field.glass::-webkit-datetime-edit-fields-wrapper {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
/* Form group (label + input) */
|
/* Form group (label + input) */
|
||||||
.form-group {
|
.form-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
@@ -128,22 +202,29 @@ textarea.form-field {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
background: var(--color-accent);
|
background: var(--color-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: none;
|
border: 1px solid #e0e0e0;
|
||||||
border-radius: var(--radius-button);
|
border-radius: var(--radius-button);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: var(--shadow-button);
|
transition: border-color 0.2s ease, transform 0.1s ease;
|
||||||
transition: opacity 0.2s ease, transform 0.1s ease;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary.glass {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--color-glass-inner), var(--color-glass-inner)) padding-box,
|
||||||
|
var(--gradient-glow) border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
opacity: 0.92;
|
border-color: var(--color-glass-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:active {
|
.btn-primary:active {
|
||||||
@@ -157,7 +238,7 @@ textarea.form-field {
|
|||||||
|
|
||||||
/* Error message */
|
/* Error message */
|
||||||
.field-error {
|
.field-error {
|
||||||
color: #fff;
|
color: var(--color-danger-solid);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding-left: 0.25rem;
|
padding-left: 0.25rem;
|
||||||
@@ -176,6 +257,132 @@ textarea.form-field {
|
|||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Glass System ── */
|
||||||
|
|
||||||
|
/* Glass surface: passive containers on gradient (cards, icon boxes) */
|
||||||
|
.glass {
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass:hover:not(input):not(textarea):not(.btn-primary) {
|
||||||
|
background: var(--color-glass-hover);
|
||||||
|
border-color: var(--color-glass-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass interactive inner: dark translucent fill for interactive elements (FAB, CTA) */
|
||||||
|
.glass-inner {
|
||||||
|
background: var(--color-glass-inner);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow border: conic gradient wrapper with halo (static) */
|
||||||
|
.glow-border {
|
||||||
|
background: var(--gradient-glow);
|
||||||
|
padding: 2px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--gradient-glow);
|
||||||
|
filter: blur(8px);
|
||||||
|
opacity: 0.3;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow border animated variant */
|
||||||
|
@property --glow-angle {
|
||||||
|
syntax: '<angle>';
|
||||||
|
initial-value: 0deg;
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border--animated {
|
||||||
|
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
|
||||||
|
animation: glow-rotate 4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-border--animated::before {
|
||||||
|
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
|
||||||
|
animation: glow-rotate 4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-rotate {
|
||||||
|
to { --glow-angle: 360deg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fixed Bottom Bar Components ── */
|
||||||
|
|
||||||
|
/* CTA wrapper (text button, e.g. "I'm attending!", "Post an update") */
|
||||||
|
.bar-cta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-cta:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-cta:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-cta-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: calc(var(--radius-button) - 2px);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
text-align: center;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Icon wrapper (e.g. calendar, bookmark buttons) */
|
||||||
|
.bar-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-icon:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-icon:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: calc(var(--radius-button) - 2px);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-icon-btn svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* Utility */
|
/* Utility */
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -192,3 +399,35 @@ textarea.form-field {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottom sheet form */
|
||||||
|
.sheet-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-form__label,
|
||||||
|
.cancel-form__label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-form__field-error {
|
||||||
|
color: var(--color-danger-solid);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-form__error {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
59
frontend/src/components/AttendeeList.vue
Normal file
59
frontend/src/components/AttendeeList.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<section class="attendee-list">
|
||||||
|
<h3 class="attendee-list__heading">
|
||||||
|
{{ attendees.length === 1 ? '1 Attendee' : `${attendees.length} Attendees` }}
|
||||||
|
</h3>
|
||||||
|
<ul v-if="attendees.length > 0" class="attendee-list__items">
|
||||||
|
<li v-for="(name, index) in attendees" :key="index" class="attendee-list__item">
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="attendee-list__empty">No attendees yet.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
attendees: string[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.attendee-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__heading {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__items {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__item {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendee-list__empty {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
frontend/src/components/BackLink.vue
Normal file
28
frontend/src/components/BackLink.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<RouterLink to="/" class="back-link" aria-label="Back to home">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
<span class="back-link__brand">fete</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link__brand {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
149
frontend/src/components/BottomSheet.vue
Normal file
149
frontend/src/components/BottomSheet.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="sheet">
|
||||||
|
<div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')">
|
||||||
|
<div
|
||||||
|
class="sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="label"
|
||||||
|
ref="sheetEl"
|
||||||
|
tabindex="-1"
|
||||||
|
:style="dragStyle"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<div class="sheet__handle" aria-hidden="true" />
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
label: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sheetEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sheetEl.value,
|
||||||
|
async (el) => {
|
||||||
|
if (el) {
|
||||||
|
await nextTick()
|
||||||
|
const firstInput = el.querySelector<HTMLElement>('input, textarea, button[type="submit"]')
|
||||||
|
if (firstInput) {
|
||||||
|
firstInput.focus()
|
||||||
|
} else {
|
||||||
|
el.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ── Drag-to-dismiss ── */
|
||||||
|
const DISMISS_THRESHOLD = 100
|
||||||
|
const dragY = ref(0)
|
||||||
|
const dragging = ref(false)
|
||||||
|
let startY = 0
|
||||||
|
|
||||||
|
const dragStyle = computed(() => {
|
||||||
|
if (!dragging.value || dragY.value <= 0) return undefined
|
||||||
|
return {
|
||||||
|
transform: `translateY(${dragY.value}px)`,
|
||||||
|
transition: 'none',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
startY = touch.clientY
|
||||||
|
dragging.value = true
|
||||||
|
dragY.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!dragging.value) return
|
||||||
|
const touch = e.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
const delta = touch.clientY - startY
|
||||||
|
if (delta > 0) e.preventDefault()
|
||||||
|
dragY.value = Math.max(0, delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (dragY.value >= DISMISS_THRESHOLD) {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
dragging.value = false
|
||||||
|
dragY.value = 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sheet-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-glass-overlay);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
|
border-bottom: none;
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet__handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-glass-border-hover);
|
||||||
|
border-radius: 2px;
|
||||||
|
align-self: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.sheet-enter-active,
|
||||||
|
.sheet-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-enter-active .sheet,
|
||||||
|
.sheet-leave-active .sheet {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-enter-from,
|
||||||
|
.sheet-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-enter-from .sheet,
|
||||||
|
.sheet-leave-to .sheet {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
154
frontend/src/components/ConfirmDialog.vue
Normal file
154
frontend/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="confirm-dialog">
|
||||||
|
<div v-if="open" class="confirm-dialog__overlay" @click.self="$emit('cancel')">
|
||||||
|
<div
|
||||||
|
class="confirm-dialog"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="title"
|
||||||
|
@keydown.escape="$emit('cancel')"
|
||||||
|
>
|
||||||
|
<p class="confirm-dialog__title">{{ title }}</p>
|
||||||
|
<p class="confirm-dialog__message">{{ message }}</p>
|
||||||
|
<div class="confirm-dialog__actions">
|
||||||
|
<button
|
||||||
|
ref="cancelBtn"
|
||||||
|
class="confirm-dialog__btn confirm-dialog__btn--cancel"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
{{ cancelLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="confirm-dialog__btn confirm-dialog__btn--confirm"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
>
|
||||||
|
{{ confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: 'Are you sure?',
|
||||||
|
message: '',
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
cancelLabel: 'Cancel',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
confirm: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const cancelBtn = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
await nextTick()
|
||||||
|
cancelBtn.value?.focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.confirm-dialog__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-glass-overlay);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
max-width: 320px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__message {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn--cancel {
|
||||||
|
background: var(--color-glass);
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__btn--confirm {
|
||||||
|
background: var(--color-danger-solid);
|
||||||
|
color: var(--color-danger-solid-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-enter-active,
|
||||||
|
.confirm-dialog-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-enter-from,
|
||||||
|
.confirm-dialog-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
58
frontend/src/components/CreateEventFab.vue
Normal file
58
frontend/src/components/CreateEventFab.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<RouterLink to="/create" class="fab glow-border" aria-label="Create event">
|
||||||
|
<span class="fab__inner glass-inner">
|
||||||
|
<span class="fab__icon" aria-hidden="true">+</span>
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(1.2rem + env(safe-area-inset-bottom));
|
||||||
|
right: 1.2rem;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab__inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.fab__icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
frontend/src/components/DateSubheader.vue
Normal file
19
frontend/src/components/DateSubheader.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<h3 class="date-subheader">{{ label }}</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.date-subheader {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
frontend/src/components/EmptyState.vue
Normal file
62
frontend/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<p class="empty-state__message">No events yet.<br />Create your first one!</p>
|
||||||
|
<RouterLink to="/create" class="empty-state__cta glow-border glow-border--animated">
|
||||||
|
<span class="empty-state__cta-inner glass-inner">Create Event</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__message {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
opacity: 0.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__cta {
|
||||||
|
max-width: 280px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__cta-inner {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: calc(var(--radius-button) - 2px);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__cta:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__cta:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__cta:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
186
frontend/src/components/EventCard.vue
Normal file
186
frontend/src/components/EventCard.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="event-card glass"
|
||||||
|
:class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }"
|
||||||
|
:style="swipeStyle"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<RouterLink :to="`/events/${eventToken}`" class="event-card__link">
|
||||||
|
<span class="event-card__title">{{ title }}</span>
|
||||||
|
<span class="event-card__time">{{ displayTime }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
|
||||||
|
{{ eventRole === 'organizer' ? 'Organizing' : eventRole === 'attendee' ? 'Attending' : 'Watching' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="event-card__delete"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Remove ${title}`"
|
||||||
|
@click.stop="$emit('delete', eventToken)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
eventToken: string
|
||||||
|
title: string
|
||||||
|
relativeTime: string
|
||||||
|
isPast: boolean
|
||||||
|
eventRole?: 'organizer' | 'attendee' | 'watcher'
|
||||||
|
timeDisplayMode?: 'clock' | 'relative'
|
||||||
|
dateTime?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
delete: [eventToken: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const displayTime = computed(() => {
|
||||||
|
if (props.timeDisplayMode === 'clock' && props.dateTime) {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(props.dateTime))
|
||||||
|
}
|
||||||
|
return props.relativeTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD = 80
|
||||||
|
|
||||||
|
const startX = ref(0)
|
||||||
|
const deltaX = ref(0)
|
||||||
|
const isSwiping = ref(false)
|
||||||
|
|
||||||
|
const swipeStyle = computed(() => {
|
||||||
|
if (deltaX.value === 0) return {}
|
||||||
|
return { transform: `translateX(${deltaX.value}px)` }
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
startX.value = touch.clientX
|
||||||
|
deltaX.value = 0
|
||||||
|
isSwiping.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
const diff = touch.clientX - startX.value
|
||||||
|
// Only allow leftward swipe
|
||||||
|
if (diff < 0) {
|
||||||
|
deltaX.value = diff
|
||||||
|
isSwiping.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (deltaX.value < -SWIPE_THRESHOLD) {
|
||||||
|
emit('delete', props.eventToken)
|
||||||
|
}
|
||||||
|
deltaX.value = 0
|
||||||
|
isSwiping.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card--past {
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: saturate(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:not(.event-card--swiping) {
|
||||||
|
transition: opacity 0.2s ease, filter 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__link {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge--organizer {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge--attendee {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__badge--watcher {
|
||||||
|
background: var(--color-glass);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__delete {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__delete:hover {
|
||||||
|
color: var(--color-danger-solid);
|
||||||
|
background: rgba(211, 47, 47, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card__delete:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
176
frontend/src/components/EventList.vue
Normal file
176
frontend/src/components/EventList.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div class="event-list">
|
||||||
|
<section
|
||||||
|
v-for="section in groupedSections"
|
||||||
|
:key="section.key"
|
||||||
|
:aria-label="section.label"
|
||||||
|
class="event-section"
|
||||||
|
>
|
||||||
|
<SectionHeader :label="section.label" :emphasized="section.emphasized" />
|
||||||
|
<div role="list">
|
||||||
|
<template v-for="group in section.dateGroups" :key="group.dateKey">
|
||||||
|
<DateSubheader v-if="group.showSubheader" :label="group.label" />
|
||||||
|
<div v-for="event in group.events" :key="event.eventToken" role="listitem">
|
||||||
|
<EventCard
|
||||||
|
:event-token="event.eventToken"
|
||||||
|
:title="event.title"
|
||||||
|
:relative-time="formatRelativeTime(event.dateTime)"
|
||||||
|
:is-past="section.key === 'past'"
|
||||||
|
:event-role="getRole(event)"
|
||||||
|
:time-display-mode="section.key === 'past' ? 'relative' : 'clock'"
|
||||||
|
:date-time="event.dateTime"
|
||||||
|
@delete="requestDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="!!pendingDeleteToken"
|
||||||
|
:title="deleteDialogTitle"
|
||||||
|
:message="deleteDialogMessage"
|
||||||
|
confirm-label="Remove"
|
||||||
|
cancel-label="Cancel"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
@cancel="cancelDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useEventStorage, isValidStoredEvent } from '../composables/useEventStorage'
|
||||||
|
import { useEventGrouping } from '../composables/useEventGrouping'
|
||||||
|
import { formatRelativeTime } from '../composables/useRelativeTime'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import EventCard from './EventCard.vue'
|
||||||
|
import SectionHeader from './SectionHeader.vue'
|
||||||
|
import DateSubheader from './DateSubheader.vue'
|
||||||
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
|
import type { StoredEvent } from '../composables/useEventStorage'
|
||||||
|
|
||||||
|
const { getStoredEvents, getRsvp, getOrganizerToken, removeEvent } = useEventStorage()
|
||||||
|
|
||||||
|
const pendingDeleteToken = ref<string | null>(null)
|
||||||
|
const deleteError = ref('')
|
||||||
|
|
||||||
|
const pendingDeleteRole = computed(() => {
|
||||||
|
if (!pendingDeleteToken.value) return null
|
||||||
|
const event = getStoredEvents().find((e) => e.eventToken === pendingDeleteToken.value)
|
||||||
|
return event ? getRole(event) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteDialogTitle = computed(() => {
|
||||||
|
return pendingDeleteRole.value === 'organizer' ? 'Cancel event?' : 'Remove event?'
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteDialogMessage = computed(() => {
|
||||||
|
if (!pendingDeleteToken.value) return ''
|
||||||
|
if (pendingDeleteRole.value === 'organizer') {
|
||||||
|
return 'This will permanently cancel the event for all attendees.'
|
||||||
|
}
|
||||||
|
const rsvp = getRsvp(pendingDeleteToken.value)
|
||||||
|
if (rsvp) {
|
||||||
|
return 'This event will be removed from your list and your attendance will be cancelled.'
|
||||||
|
}
|
||||||
|
return 'This event will be removed from your list.'
|
||||||
|
})
|
||||||
|
|
||||||
|
function requestDelete(eventToken: string) {
|
||||||
|
deleteError.value = ''
|
||||||
|
const role = getRole(getStoredEvents().find((e) => e.eventToken === eventToken)!)
|
||||||
|
if (role === 'watcher') {
|
||||||
|
removeEvent(eventToken)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingDeleteToken.value = eventToken
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!pendingDeleteToken.value) return
|
||||||
|
|
||||||
|
const eventToken = pendingDeleteToken.value
|
||||||
|
const organizerToken = getOrganizerToken(eventToken)
|
||||||
|
|
||||||
|
if (organizerToken) {
|
||||||
|
try {
|
||||||
|
const { response } = await api.PATCH('/events/{eventToken}', {
|
||||||
|
params: {
|
||||||
|
path: { eventToken },
|
||||||
|
query: { organizerToken },
|
||||||
|
},
|
||||||
|
body: { cancelled: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 204 && response.status !== 409 && response.status !== 404) {
|
||||||
|
deleteError.value = 'Could not cancel event. Please try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
deleteError.value = 'Could not cancel event. Please try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEvent(eventToken)
|
||||||
|
pendingDeleteToken.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsvp = getRsvp(eventToken)
|
||||||
|
|
||||||
|
if (rsvp) {
|
||||||
|
try {
|
||||||
|
const { response } = await api.DELETE('/events/{eventToken}/rsvps/{rsvpToken}', {
|
||||||
|
params: {
|
||||||
|
path: {
|
||||||
|
eventToken: eventToken,
|
||||||
|
rsvpToken: rsvp.rsvpToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 204 && response.status !== 404) {
|
||||||
|
deleteError.value = 'Could not cancel attendance. Please try again.'
|
||||||
|
pendingDeleteToken.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
deleteError.value = 'Could not cancel attendance. Please try again.'
|
||||||
|
pendingDeleteToken.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEvent(eventToken)
|
||||||
|
pendingDeleteToken.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
pendingDeleteToken.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole(event: StoredEvent): 'organizer' | 'attendee' | 'watcher' {
|
||||||
|
if (event.organizerToken) return 'organizer'
|
||||||
|
if (event.rsvpToken) return 'attendee'
|
||||||
|
return 'watcher'
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedSections = computed(() => {
|
||||||
|
const valid = getStoredEvents().filter(isValidStoredEvent)
|
||||||
|
return useEventGrouping(valid)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-section [role="list"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
260
frontend/src/components/RsvpBar.vue
Normal file
260
frontend/src/components/RsvpBar.vue
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rsvp-bar">
|
||||||
|
<div class="rsvp-bar__inner">
|
||||||
|
<!-- Status state: already RSVPed -->
|
||||||
|
<div v-if="hasRsvp" class="rsvp-bar__status-wrapper">
|
||||||
|
<div class="rsvp-bar__status-row">
|
||||||
|
<div
|
||||||
|
class="rsvp-bar__status"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-expanded="expanded"
|
||||||
|
aria-label="You're attending. Tap to show cancel option."
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
@keydown.enter.prevent="expanded = !expanded"
|
||||||
|
@keydown.space.prevent="expanded = !expanded"
|
||||||
|
@keydown.escape="expanded = false"
|
||||||
|
>
|
||||||
|
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||||
|
<span class="rsvp-bar__text">You're attending!</span>
|
||||||
|
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true">›</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rsvp-bar__calendar-glass"
|
||||||
|
type="button"
|
||||||
|
aria-label="Add to calendar"
|
||||||
|
@click="$emit('calendar')"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Transition name="rsvp-bar-cancel">
|
||||||
|
<button
|
||||||
|
v-if="expanded"
|
||||||
|
class="rsvp-bar__cancel"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
Cancel RSVP
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA state: no RSVP yet -->
|
||||||
|
<div v-else class="rsvp-bar__row">
|
||||||
|
<div class="bar-icon glow-border glow-border--animated">
|
||||||
|
<button
|
||||||
|
class="bar-icon-btn glass-inner"
|
||||||
|
type="button"
|
||||||
|
:aria-label="bookmarked ? 'Stop watching this event' : 'Watch this event'"
|
||||||
|
@click="$emit('bookmark')"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" :fill="bookmarked ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bar-cta glow-border glow-border--animated">
|
||||||
|
<button class="bar-cta-btn glass-inner" type="button" @click="$emit('open')">
|
||||||
|
I'm attending!
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bar-icon glow-border glow-border--animated">
|
||||||
|
<button
|
||||||
|
class="bar-icon-btn glass-inner"
|
||||||
|
type="button"
|
||||||
|
aria-label="Add to calendar"
|
||||||
|
@click="$emit('calendar')"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hasRsvp?: boolean
|
||||||
|
bookmarked?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
open: []
|
||||||
|
cancel: []
|
||||||
|
bookmark: []
|
||||||
|
calendar: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.hasRsvp, () => {
|
||||||
|
expanded.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('.rsvp-bar__status-wrapper')) {
|
||||||
|
expanded.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(expanded, (isExpanded) => {
|
||||||
|
if (isExpanded) {
|
||||||
|
document.addEventListener('click', onClickOutside, { capture: true })
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', onClickOutside, { capture: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rsvp-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: var(--spacing-md) var(--content-padding);
|
||||||
|
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.rsvp-bar__status-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__status-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__status {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__status:hover {
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__check {
|
||||||
|
color: #4caf50;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__chevron {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__chevron--open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__cancel {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ef5350;
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__cancel:hover {
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar-cancel-enter-active,
|
||||||
|
.rsvp-bar-cancel-leave-active {
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar-cancel-enter-from,
|
||||||
|
.rsvp-bar-cancel-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Calendar button — glassmorphic variant (post-RSVP status row) */
|
||||||
|
.rsvp-bar__calendar-glass {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||||
|
border: 1px solid var(--color-glass-border);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 0;
|
||||||
|
transition: transform 0.1s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__calendar-glass:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__calendar-glass:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-bar__calendar-glass svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
27
frontend/src/components/SectionHeader.vue
Normal file
27
frontend/src/components/SectionHeader.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<h2 class="section-header" :class="{ 'section-header--emphasized': emphasized }">
|
||||||
|
{{ label }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
emphasized?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-header {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header--emphasized {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
50
frontend/src/components/__tests__/AttendeeList.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import AttendeeList from '../AttendeeList.vue'
|
||||||
|
|
||||||
|
describe('AttendeeList', () => {
|
||||||
|
it('renders attendee names as list items', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: ['Alice', 'Bob', 'Charlie'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = wrapper.findAll('.attendee-list__item')
|
||||||
|
expect(items).toHaveLength(3)
|
||||||
|
expect(items[0]!.text()).toBe('Alice')
|
||||||
|
expect(items[1]!.text()).toBe('Bob')
|
||||||
|
expect(items[2]!.text()).toBe('Charlie')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state message when no attendees', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__empty').text()).toBe('No attendees yet.')
|
||||||
|
expect(wrapper.find('.attendee-list__items').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows plural count heading for multiple attendees', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('5 Attendees')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows singular count heading for one attendee', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: ['Alice'] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('1 Attendee')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows zero count heading for no attendees', () => {
|
||||||
|
const wrapper = mount(AttendeeList, {
|
||||||
|
props: { attendees: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('0 Attendees')
|
||||||
|
})
|
||||||
|
})
|
||||||
51
frontend/src/components/__tests__/BottomSheet.spec.ts
Normal file
51
frontend/src/components/__tests__/BottomSheet.spec.ts
Normal file
@@ -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: '<p>Sheet content</p>' },
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal file
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { mount, VueWrapper } from '@vue/test-utils'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper
|
||||||
|
|
||||||
|
function mountDialog(props: Record<string, unknown> = {}) {
|
||||||
|
wrapper = mount(ConfirmDialog, {
|
||||||
|
props: {
|
||||||
|
open: true,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialog() {
|
||||||
|
return document.body.querySelector('.confirm-dialog')
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlay() {
|
||||||
|
return document.body.querySelector('.confirm-dialog__overlay')
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ConfirmDialog', () => {
|
||||||
|
it('renders when open is true', () => {
|
||||||
|
mountDialog()
|
||||||
|
expect(dialog()).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when open is false', () => {
|
||||||
|
mountDialog({ open: false })
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays default title', () => {
|
||||||
|
mountDialog()
|
||||||
|
expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Are you sure?')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays custom title and message', () => {
|
||||||
|
mountDialog({
|
||||||
|
title: 'Remove event?',
|
||||||
|
message: 'This cannot be undone.',
|
||||||
|
})
|
||||||
|
expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Remove event?')
|
||||||
|
expect(dialog()!.querySelector('.confirm-dialog__message')!.textContent).toBe('This cannot be undone.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays custom button labels', () => {
|
||||||
|
mountDialog({
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
cancelLabel: 'Keep',
|
||||||
|
})
|
||||||
|
const buttons = dialog()!.querySelectorAll('.confirm-dialog__btn')
|
||||||
|
expect(buttons[0]!.textContent!.trim()).toBe('Keep')
|
||||||
|
expect(buttons[1]!.textContent!.trim()).toBe('Delete')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits confirm when confirm button is clicked', async () => {
|
||||||
|
mountDialog()
|
||||||
|
const btn = dialog()!.querySelector('.confirm-dialog__btn--confirm') as HTMLElement
|
||||||
|
btn.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('confirm')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits cancel when cancel button is clicked', async () => {
|
||||||
|
mountDialog()
|
||||||
|
const btn = dialog()!.querySelector('.confirm-dialog__btn--cancel') as HTMLElement
|
||||||
|
btn.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits cancel when overlay is clicked', async () => {
|
||||||
|
mountDialog()
|
||||||
|
const el = overlay() as HTMLElement
|
||||||
|
el.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits cancel when Escape key is pressed', async () => {
|
||||||
|
mountDialog()
|
||||||
|
const el = dialog() as HTMLElement
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses cancel button when opened', async () => {
|
||||||
|
mountDialog({ open: false })
|
||||||
|
await wrapper.setProps({ open: true })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
const cancelBtn = dialog()!.querySelector('.confirm-dialog__btn--cancel')
|
||||||
|
expect(document.activeElement).toBe(cancelBtn)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has alertdialog role and aria-modal', () => {
|
||||||
|
mountDialog()
|
||||||
|
const el = dialog() as HTMLElement
|
||||||
|
expect(el.getAttribute('role')).toBe('alertdialog')
|
||||||
|
expect(el.getAttribute('aria-modal')).toBe('true')
|
||||||
|
})
|
||||||
|
})
|
||||||
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal file
17
frontend/src/components/__tests__/DateSubheader.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import DateSubheader from '../DateSubheader.vue'
|
||||||
|
|
||||||
|
describe('DateSubheader', () => {
|
||||||
|
it('renders the date label as an h3', () => {
|
||||||
|
const wrapper = mount(DateSubheader, { props: { label: 'Wed, 12 Mar' } })
|
||||||
|
const h3 = wrapper.find('h3')
|
||||||
|
expect(h3.exists()).toBe(true)
|
||||||
|
expect(h3.text()).toBe('Wed, 12 Mar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the date-subheader class', () => {
|
||||||
|
const wrapper = mount(DateSubheader, { props: { label: 'Fri, 14 Mar' } })
|
||||||
|
expect(wrapper.find('.date-subheader').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal file
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EmptyState from '../EmptyState.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/create', name: 'create', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountEmptyState() {
|
||||||
|
return mount(EmptyState, {
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EmptyState', () => {
|
||||||
|
it('renders an inviting message', () => {
|
||||||
|
const wrapper = mountEmptyState()
|
||||||
|
expect(wrapper.text()).toContain('No events yet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a Create Event link', () => {
|
||||||
|
const wrapper = mountEmptyState()
|
||||||
|
const link = wrapper.find('a')
|
||||||
|
expect(link.exists()).toBe(true)
|
||||||
|
expect(link.text()).toContain('Create Event')
|
||||||
|
expect(link.attributes('href')).toBe('/create')
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user