Migrate project artifacts to spec-kit format

- Move cross-cutting docs (personas, design system, implementation phases,
  Ideen.md) to .specify/memory/
- Move cross-cutting research and plans to .specify/memory/research/ and
  .specify/memory/plans/
- Extract 5 setup tasks from spec/setup-tasks.md into individual
  specs/001-005/spec.md files with spec-kit template format
- Extract 20 user stories from spec/userstories.md into individual
  specs/006-026/spec.md files with spec-kit template format
- Relocate feature-specific research and plan docs into specs/[feature]/
- Add spec-kit constitution, templates, scripts, and slash commands
- Slim down CLAUDE.md to Claude-Code-specific config, delegate principles
  to .specify/memory/constitution.md
- Update ralph.sh with stream-json output and per-iteration logging
- Delete old spec/ and docs/agents/ directories
- Gitignore Ralph iteration JSONL logs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 20:19:41 +01:00
parent 0b2b84dafc
commit 6aeb4b8bca
83 changed files with 6486 additions and 660 deletions

View File

@@ -0,0 +1,179 @@
# Implementation Plan: T-1 — Initialize Monorepo Structure
## Context
This is the first setup task for the fete project — a privacy-focused, self-hostable PWA for event announcements and RSVPs. The repository currently contains only documentation, specs, and Ralph loop infrastructure. No application code exists yet.
The tech stack was decided during the research phase (see `docs/agents/research/2026-03-04-t1-monorepo-setup.md`):
- **Backend:** Java 25 (latest LTS), ~~Spring Boot 4.0~~ Spring Boot 3.5.11 (see addendum), Maven, hexagonal architecture, base package `de.fete`
- **Java installation:** Via SDKMAN! (`sdk install java 25-open`), no system-level JDK
- **Frontend:** Vue 3, Vite, TypeScript, Vue Router, Vitest
- **Node.js:** Latest LTS (24)
## Phase 1: Backend Scaffold
### 1.1 Create directory structure
Create `backend/` with the hexagonal architecture package layout:
```
backend/
├── pom.xml
├── mvnw, mvnw.cmd
├── .mvn/wrapper/maven-wrapper.properties
└── src/
├── main/
│ ├── java/de/fete/
│ │ ├── FeteApplication.java
│ │ ├── domain/
│ │ │ ├── model/package-info.java
│ │ │ └── port/
│ │ │ ├── in/package-info.java
│ │ │ └── out/package-info.java
│ │ ├── application/
│ │ │ └── service/package-info.java
│ │ ├── adapter/
│ │ │ ├── in/web/package-info.java
│ │ │ └── out/persistence/package-info.java
│ │ └── config/package-info.java
│ └── resources/
│ └── application.properties
└── test/
└── java/de/fete/
└── FeteApplicationTest.java
```
### 1.2 Create `pom.xml`
- Parent: `spring-boot-starter-parent` (latest 4.0.x, or fall back to 3.5.x if 4.0 is unavailable)
- GroupId: `de.fete`, ArtifactId: `fete-backend`
- Java version: 25
- Dependencies:
- `spring-boot-starter-web` (embedded Tomcat, Spring MVC)
- `spring-boot-starter-test` (scope: test)
- NO JPA yet (deferred to T-4)
### 1.3 Add Maven Wrapper
Generate via `mvn wrapper:wrapper` (or manually create the wrapper files). This ensures contributors and Docker builds don't need a Maven installation.
### 1.4 Create application class
`FeteApplication.java` in `de.fete` — minimal `@SpringBootApplication` with `main()`.
### 1.5 Create a minimal health endpoint
A simple `@RestController` in `adapter/in/web/` that responds to `GET /health` with HTTP 200 and a JSON body like `{"status": "ok"}`. This satisfies:
- The user's requirement that a REST request returns 200
- Prepares for T-2's health-check requirement
### 1.6 Create package-info.java markers
One per leaf package, documenting the package's purpose and architectural constraints. These serve as Git directory markers AND developer documentation.
### 1.7 Write integration test
`FeteApplicationTest.java`:
- `@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)`
- Test 1: Spring context loads successfully
- Test 2: HTTP GET to `/health` returns 200
### 1.8 Verify backend
- [x] `cd backend && ./mvnw test` — tests pass (context loads + health endpoint returns 200)
- [x] `cd backend && ./mvnw spring-boot:run` — app starts on port 8080
- [x] `curl http://localhost:8080/health` — returns 200 with `{"status": "ok"}`
## Phase 2: Frontend Scaffold
### 2.1 Scaffold Vue project
Run `npm create vue@latest` in `frontend/` with these options:
- TypeScript: Yes
- Vue Router: Yes
- Pinia: No
- Vitest: Yes
- ESLint: Yes
- Prettier: Yes
- E2E: No
If the interactive prompts can't be automated, create the project manually based on the `create-vue` template output.
### 2.2 Add `composables/` directory
Create `src/composables/` (empty, with a `.gitkeep` or initial placeholder) — convention for Composition API composables needed later.
### 2.3 Verify frontend builds and tests
- [x] `cd frontend && npm install`
- [x] `cd frontend && npm run build` — builds successfully
- [x] `cd frontend && npm run test:unit` — Vitest runs (sample test passes)
### 2.4 Verify via browser
- [x] `cd frontend && npm run dev` — starts dev server
- [x] Use `browser-interactive-testing` skill to visit the dev server URL and verify the page loads
## Phase 3: Shared Files
### 3.1 Update `.gitignore`
Extend the existing `.gitignore` with sections for:
- Java/Maven (target/, *.class, *.jar, Maven release files, crash logs)
- Node.js/Vue/Vite (node_modules/, dist/, build/, vite temp files)
- Environment files (.env, .env.* but NOT .env.example)
- Editor swap files (*.swp, *.swo, *~)
- Spring Boot (.springBeans, .sts4-cache)
Keep existing entries (IDE, OS, Claude settings) intact.
### 3.2 Verify .gitignore
Run `git status` after building both projects to confirm build artifacts are properly ignored.
- [x] `git status` shows no `target/`, `node_modules/`, `dist/`, or other build artifacts
## Verification Checklist (Done Criteria)
### Backend
- [x] `cd backend && ./mvnw test` passes (integration test: context loads + GET /health → 200)
- [x] `cd backend && ./mvnw spring-boot:run` starts successfully
- [x] `curl http://localhost:8080/health` returns HTTP 200
- [x] Hexagonal package structure exists with package-info.java markers
### Frontend
- [x] `cd frontend && npm run build` succeeds
- [x] `cd frontend && npm run test:unit` runs and passes
- [x] Browser verification via `browser-interactive-testing` skill confirms page loads
### Shared
- [x] `.gitignore` covers Java/Maven + Node/Vue artifacts
- [x] `git status` shows no unintended tracked build artifacts
- [x] T-1 acceptance criteria from `spec/setup-tasks.md` are met
## Files to Create/Modify
**Create:**
- `backend/pom.xml`
- `backend/mvnw`, `backend/mvnw.cmd`, `backend/.mvn/wrapper/maven-wrapper.properties`
- `backend/src/main/java/de/fete/FeteApplication.java`
- `backend/src/main/java/de/fete/adapter/in/web/HealthController.java`
- `backend/src/main/java/de/fete/domain/model/package-info.java`
- `backend/src/main/java/de/fete/domain/port/in/package-info.java`
- `backend/src/main/java/de/fete/domain/port/out/package-info.java`
- `backend/src/main/java/de/fete/application/service/package-info.java`
- `backend/src/main/java/de/fete/adapter/in/web/package-info.java`
- `backend/src/main/java/de/fete/adapter/out/persistence/package-info.java`
- `backend/src/main/java/de/fete/config/package-info.java`
- `backend/src/main/resources/application.properties`
- `backend/src/test/java/de/fete/FeteApplicationTest.java`
- `frontend/` (entire scaffolded project via create-vue)
**Modify:**
- `.gitignore` (extend with Java/Maven + Node/Vue sections)
## Addendum: Spring Boot 4.0 → 3.5 Pivot
During implementation, Spring Boot 4.0.3 was abandoned in favor of **3.5.11**. The 4.0 release reorganized test infrastructure into new modules and packages (`TestRestTemplate``spring-boot-resttestclient`, `AutoConfigureMockMvc` → unknown location) without adequate migration documentation. Multiple attempts to resolve compilation and auto-configuration errors failed, making it impractical for the scaffold phase.
The pivot has no impact on project architecture or future tasks. Migration to 4.x can be revisited once the ecosystem matures. See the research report addendum for full details.

View File

@@ -0,0 +1,477 @@
---
date: 2026-03-04T00:19:03+01:00
git_commit: 7b460dd322359dc1fa3ca0dc950a91c607163977
branch: master
topic: "T-1: Initialize monorepo structure — Tech stack research"
tags: [research, codebase, t-1, scaffolding, spring-boot, vue, maven, vite, hexagonal-architecture]
status: complete
---
# Research: T-1 — Initialize Monorepo Structure
## Research Question
What are the current versions, scaffolding approaches, and architectural patterns needed to implement T-1 (Initialize monorepo structure) with the specified tech stack: Java (latest LTS), Spring Boot, Maven, hexagonal/onion architecture backend + Svelte with Vite frontend?
## Summary
This research covers all technical aspects needed for T-1. The spec requires a monorepo with `backend/` and `frontend/` directories, both building successfully as empty scaffolds. The key findings and open decisions are:
1. **Java 25** is the current LTS (Sep 2025). Spring Boot **4.0.3** is the latest stable — but **3.5.x** is more battle-tested. This is an architectural decision.
2. **Svelte 5** is stable (since Oct 2024). The SPA router ecosystem for plain Svelte 5 is weak — **SvelteKit in SPA mode** is the pragmatic alternative.
3. **Hexagonal architecture**: Single Maven module with package-level separation + ArchUnit enforcement. Base package `com.fete`.
4. **TypeScript** for the frontend is recommended but is a decision point.
## Detailed Findings
### 1. Java Version
**Java 25 (LTS)** — released September 16, 2025.
- Premier support through Sep 2030, extended support through Sep 2033.
- Supersedes Java 21 (Sep 2023) as the current LTS.
- Java 21 is still supported but free updates end Sep 2026.
- Next LTS: Java 29 (Sep 2027).
**Recommendation:** Java 25. Longest support runway, both Spring Boot 3.5 and 4.0 support it.
### 2. Spring Boot Version
Two actively supported lines as of March 2026:
| Version | Latest Patch | OSS Support Ends | Java Baseline | Key Dependencies |
|---------|-------------|-------------------|---------------|-----------------|
| **4.0.x** | 4.0.3 | Dec 2026 | Java 17+ (up to 25) | Spring Framework 7.0, Jakarta EE 11, Hibernate 7.1, Jackson 3.0, Tomcat 11.0 |
| **3.5.x** | 3.5.11 | Jun 2026 | Java 17+ (up to 25) | Spring Framework 6.x, Jakarta EE 10, Hibernate 6.x, Jackson 2.x, Tomcat 10.x |
**Trade-offs:**
| Factor | Spring Boot 4.0 | Spring Boot 3.5 |
|--------|----------------|-----------------|
| Support runway | Dec 2026 (longer) | Jun 2026 (shorter) |
| Ecosystem maturity | Jackson 3.0 + Hibernate 7.1 are new major versions; fewer community examples | Battle-tested, large ecosystem of examples |
| Migration burden | None (greenfield) | None (greenfield) |
| Forward-looking | Yes | Will need migration to 4.x eventually |
**Decision needed:** Spring Boot 4.0 (forward-looking) vs. 3.5 (more battle-tested). Both support Java 25.
### 3. Maven
- **Maven version:** 3.9.12 (latest stable; Maven 4.0.0 is still RC).
- **Maven Wrapper:** Yes, include it. Modern "only-script" distribution — no binary JAR in repo. Scripts `mvnw`/`mvnw.cmd` + `.mvn/wrapper/maven-wrapper.properties` are committed.
- Benefits: deterministic builds in Docker, no Maven pre-install requirement for contributors or CI.
**Minimal dependencies for Spring Boot + PostgreSQL:**
```xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.3</version> <!-- or 3.5.11 -->
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
```
Note: For the empty scaffold (T-1), JPA autoconfig must be excluded or deferred since there's no DB yet. Either omit `spring-boot-starter-data-jpa` until T-4, or add `spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration` to `application.properties`.
### 4. Svelte + Vite Frontend
**Current versions:**
- Svelte: **5.53.6** (stable since Oct 2024, runes-based reactivity)
- Vite: **7.3.1** (stable; Vite 8 is in beta)
**Svelte 5 key changes from Svelte 4:**
- Reactive state: `let count = $state(0)` (explicit rune) instead of implicit `let count = 0`
- Derived values: `$derived()` replaces `$:` blocks
- Props: `$props()` replaces `export let`
- Reactivity works in `.svelte.ts` files too (not just components)
### 5. SvelteKit vs. Plain Svelte — Decision Point
The spec says "Svelte with Vite as bundler" for an SPA with a separate REST backend.
**Option A: Plain Svelte + Vite + third-party router**
| Pro | Con |
|-----|-----|
| Simpler mental model | Router ecosystem broken for Svelte 5: `svelte-spa-router` has no Svelte 5 support (issue #318) |
| Literal spec wording | Only unmaintained/low-adoption alternatives exist |
| No unused SSR concepts | Violates dependency statute ("actively maintained") |
**Option B: SvelteKit in SPA mode (adapter-static + `ssr: false`)**
| Pro | Con |
|-----|-----|
| Built-in file-based routing (maintained by Svelte team) | Has SSR concepts that exist but are unused |
| First-class Vitest integration (T-4 requirement) | Slightly larger framework footprint |
| SPA config is 3 lines of code | SEO concerns (irrelevant for this app) |
| Output is static HTML/JS/CSS — no Node.js runtime needed | |
| `npx sv create` scaffolds TS/ESLint/Vitest in one step | |
**SPA configuration (total effort):**
1. `npm i -D @sveltejs/adapter-static`
2. `svelte.config.js`: `adapter: adapter({ fallback: '200.html' })`
3. `src/routes/+layout.js`: `export const ssr = false;`
**Decision needed:** SvelteKit in SPA mode (pragmatic, solves the router problem) vs. plain Svelte + Vite (minimalist, but router ecosystem is a real problem).
### 6. TypeScript vs. JavaScript — Decision Point
**Arguments for TypeScript:**
- Java backend is strongly typed; TS catches API contract drift at compile time.
- Svelte 5 has first-class TS support, including TS in markup.
- The API client layer (T-4) benefits most from type safety.
- Zero-config in both SvelteKit and the `svelte-ts` Vite template.
- Rich Harris: "TypeScript for apps, JSDoc for libraries." This is an app.
**Arguments against:**
- KISS/grugbrain principle — TS adds cognitive overhead.
- Svelte's compiler already catches many errors.
**Verdict from research:** TS overhead in Svelte 5 is minimal (`let count: number = $state(0)` vs. `let count = $state(0)`). The real value is in the API client layer and shared types.
### 7. Node.js Version
| Version | Status | End of Life |
|---------|--------|-------------|
| Node.js 24 | Active LTS | ~April 2028 |
| Node.js 22 | Active LTS | April 2027 |
| Node.js 20 | Maintenance LTS | April 2026 (EOL imminent) |
**Recommendation:** Target Node.js 22 LTS as minimum. Docker image should use `node:22-alpine`.
### 8. Hexagonal Architecture — Package Structure
**Approach:** Single Maven module with package-level separation. Multi-module Maven is overkill for a small app. ArchUnit test enforces dependency rules.
**Package structure:**
```
com.fete
├── FeteApplication.java # @SpringBootApplication
├── domain/
│ ├── model/ # Entities, value objects (plain Java, no framework annotations)
│ └── port/
│ ├── in/ # Driving port interfaces (use cases)
│ └── out/ # Driven port interfaces (repositories)
├── application/
│ └── service/ # Use case implementations (@Service)
├── adapter/
│ ├── in/
│ │ └── web/ # REST controllers, DTOs, mappers
│ └── out/
│ └── persistence/ # JPA entities, Spring Data repos, mappers
└── config/ # @Configuration classes
```
**Dependency flow (strict):**
```
domain → nothing
application → domain only
adapter → application + domain
config → everything (wiring)
```
**Spring annotations by layer:**
| Layer | Annotations | Rationale |
|-------|-------------|-----------|
| `domain.model` | None | Plain Java — no framework coupling |
| `domain.port` | None | Plain Java interfaces |
| `application.service` | `@Service` only | Pragmatic compromise for component scanning |
| `adapter.in.web` | `@RestController`, `@GetMapping`, etc. | Framework adapter layer |
| `adapter.out.persistence` | `@Entity`, `@Repository`, `@Table`, etc. | Framework adapter layer |
| `config` | `@Configuration`, `@Bean` | Wiring layer |
**Domain purity:** Persistence has its own JPA entity classes (e.g., `EventJpaEntity`) separate from domain model classes. Mappers convert between them.
**Empty directory markers:** Use `package-info.java` in each leaf package. Documents package purpose, allows Git to track the directory, and aids component scanning.
**Base package:** `com.fete` (Maven convention, clean, short).
### 9. .gitignore
The existing `.gitignore` covers IDE files (`.idea/`, `.vscode/`, `*.iml`), OS files (`.DS_Store`, `Thumbs.db`), and Claude settings. The following sections need to be added:
**Java/Maven:**
- `*.class`, `*.jar`, `*.war`, `*.ear`, `*.nar` — compiled artifacts
- `target/` — Maven build output
- Maven release plugin files (`pom.xml.tag`, `pom.xml.releaseBackup`, etc.)
- `.mvn/wrapper/maven-wrapper.jar` — downloaded automatically
- Eclipse files (`.classpath`, `.project`, `.settings/`, `.factorypath`)
- Spring Boot (`.springBeans`, `.sts4-cache`)
- Java crash logs (`hs_err_pid*`, `replay_pid*`)
- `*.log`
**Node.js/Svelte/Vite:**
- `node_modules/`
- `dist/`, `build/` — build output
- `.svelte-kit/` — SvelteKit generated files
- `vite.config.js.timestamp-*`, `vite.config.ts.timestamp-*` — Vite temp files
- `.env`, `.env.*` (but NOT `.env.example`)
- `npm-debug.log*`
**Editor files:**
- `*.swp`, `*.swo`, `*~` — Vim/editor backup files
- `\#*\#`, `.#*` — Emacs
**Committed (NOT ignored):**
- `mvnw`, `mvnw.cmd`, `.mvn/wrapper/maven-wrapper.properties`
- `package-lock.json`
### 10. Existing Repository State
The repository currently contains:
- `CLAUDE.md` — Project statutes
- `README.md` — With tech stack docs and docker-compose example
- `LICENSE` — GPL
- `.gitignore` — Partial (IDE + OS only)
- `Ideen.md` — German idea document
- `spec/` — User stories, personas, setup tasks, implementation phases
- `.ralph/` — Ralph loop infrastructure
- `ralph.sh` — Ralph loop runner
- `review-findings.md` — Review notes
No `backend/` or `frontend/` directories exist yet. No `Dockerfile` exists yet (listed in README project structure, but deferred to T-2).
## Decisions Required Before Implementation
These are architectural decisions that require approval per CLAUDE.md governance statutes:
| # | Decision | Options | Recommendation |
|---|----------|---------|----------------|
| 1 | Spring Boot version | 4.0.3 (latest, longer support) vs. 3.5.11 (battle-tested, shorter support) | 4.0.3 — greenfield project, no migration burden, longer support |
| 2 | SvelteKit vs. plain Svelte | SvelteKit SPA mode vs. plain Svelte + third-party router | SvelteKit SPA mode — router ecosystem for plain Svelte 5 is broken |
| 3 | TypeScript vs. JavaScript | TypeScript (type safety on API boundary) vs. JavaScript (simpler) | TypeScript — minimal overhead in Svelte 5, catches API contract drift |
| 4 | Spring Boot JPA in T-1? | Include `spring-boot-starter-data-jpa` now (exclude autoconfig) vs. add it in T-4 | Defer to T-4 — T-1 is "empty scaffold", JPA needs a datasource |
## Code References
- `spec/setup-tasks.md` — T-1 acceptance criteria
- `spec/implementation-phases.md:9-14` — Phase 0 task order
- `CLAUDE.md:36-43` — Dependency statutes
- `Ideen.md:76-78` — Tech stack decisions (already made)
- `.gitignore` — Current state (needs extension)
- `README.md:112-119` — Documented project structure (target)
## Architecture Documentation
### Target Repository Layout (after T-1)
```
fete/
├── backend/
│ ├── pom.xml
│ ├── mvnw
│ ├── mvnw.cmd
│ ├── .mvn/wrapper/maven-wrapper.properties
│ └── src/
│ ├── main/
│ │ ├── java/com/fete/
│ │ │ ├── FeteApplication.java
│ │ │ ├── domain/model/ (package-info.java)
│ │ │ ├── domain/port/in/ (package-info.java)
│ │ │ ├── domain/port/out/ (package-info.java)
│ │ │ ├── application/service/ (package-info.java)
│ │ │ ├── adapter/in/web/ (package-info.java)
│ │ │ ├── adapter/out/persistence/(package-info.java)
│ │ │ └── config/ (package-info.java)
│ │ └── resources/
│ │ └── application.properties
│ └── test/java/com/fete/
│ └── FeteApplicationTest.java
├── frontend/
│ ├── package.json
│ ├── package-lock.json
│ ├── svelte.config.js
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── src/
│ │ ├── app.html
│ │ ├── routes/
│ │ │ ├── +layout.js (ssr = false)
│ │ │ └── +page.svelte
│ │ └── lib/
│ └── static/
├── spec/
├── .gitignore (extended)
├── CLAUDE.md
├── README.md
├── LICENSE
└── Ideen.md
```
### Build Commands (Target State)
| What | Command |
|------|---------|
| Backend build | `cd backend && ./mvnw package` |
| Backend test | `cd backend && ./mvnw test` |
| Frontend install | `cd frontend && npm install` |
| Frontend build | `cd frontend && npm run build` |
| Frontend test | `cd frontend && npm test` |
| Frontend dev | `cd frontend && npm run dev` |
## Open Questions
All resolved — see Follow-up Research below.
## Follow-up Research: Frontend Pivot to Vue 3 (2026-03-04)
### Context
During decision review, the developer raised concerns about the Svelte 5 ecosystem maturity (specifically the broken third-party router situation signaling a smaller, less mature ecosystem). After comparing Svelte 5 vs Vue 3 on ecosystem size, community support, team size, and stability, the decision was made to pivot from Svelte to **Vue 3**.
### Rationale
- Vue 3 has a significantly larger ecosystem and community
- Official, battle-tested packages for all needs (Vue Router, Pinia, Vitest)
- Vite was created by Evan You (Vue's creator) — first-class integration
- Vue 3 Composition API is modern and elegant while being mature (stable since 2020)
- Larger team, broader funding, more StackOverflow answers and tutorials
### Vue 3 Stack — Research Findings
**Current versions (March 2026):**
| Package | Version | Notes |
|---------|---------|-------|
| Vue | 3.5.29 | Stable. Vue 3.6 (Vapor Mode) is in beta |
| Vue Router | 5.0.3 | Includes file-based routing from unplugin-vue-router. Drop-in from v4 for manual routes |
| Vite | 7.3.1 | Stable. Vite 8 (Rolldown) is in beta |
| Vitest | 4.0.18 | Stable. Browser Mode graduated from experimental |
| @vue/test-utils | 2.4.6 | Official component testing utilities |
| create-vue | 3.22.0 | Official scaffolding tool |
| Node.js | 24 LTS | Latest LTS, support through ~April 2028 |
**Scaffolding:** `npm create vue@latest` (official Vue CLI scaffolding). Interactive prompts offer TypeScript, Vue Router, Pinia, Vitest, ESLint, Prettier out of the box.
**Selected options for fete:**
- TypeScript: **Yes**
- Vue Router: **Yes**
- Pinia: **No** — Composition API (`ref`/`reactive`) + localStorage is sufficient for this app's simple state
- Vitest: **Yes**
- ESLint: **Yes**
- Prettier: **Yes**
- E2E testing: **No** (not needed for T-1)
**Project structure (scaffolded by create-vue):**
```
frontend/
├── public/
│ └── favicon.ico
├── src/
│ ├── assets/ # Static assets (CSS, images)
│ ├── components/ # Reusable components
│ ├── composables/ # Composition API composables (added manually)
│ ├── router/ # Vue Router config (index.ts)
│ ├── views/ # Route-level page components
│ ├── App.vue # Root component
│ └── main.ts # Entry point
├── index.html
├── package.json
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── vite.config.ts
├── eslint.config.js
├── .prettierrc.json
├── env.d.ts
└── README.md
```
**Key conventions:**
- `src/views/` for route page components (not `src/pages/` — that's Nuxt)
- `src/components/` for reusable components
- `src/composables/` for Composition API composables (e.g., `useStorage.ts`)
- `src/router/index.ts` for route definitions
### Resolved Decisions
| # | Decision | Resolution |
|---|----------|------------|
| 1 | Spring Boot version | ~~**4.0.3**~~**3.5.11** — see addendum below |
| 2 | Frontend framework | **Vue 3** — pivot from Svelte due to ecosystem maturity concerns |
| 3 | TypeScript | **Yes** — confirmed by developer |
| 4 | Node.js version | **24 LTS** (latest LTS) |
| 5 | Base package | **`de.fete`** (not `com.fete`) |
| 6 | JPA in T-1 | **Defer to T-4** — T-1 is empty scaffold, JPA needs a datasource |
| 7 | State management | **No Pinia** — Composition API + localStorage sufficient |
### Addendum: Spring Boot 4.0 → 3.5 Pivot (2026-03-04)
During T-1 implementation, Spring Boot 4.0.3 proved unworkable for the scaffold phase. The 4.0 release massively reorganized internal packages — test infrastructure classes (`TestRestTemplate`, `AutoConfigureMockMvc`, etc.) were moved into new modules with different package paths. The Spring Boot 4.0 Migration Guide did not cover these changes adequately, and resolving the issues required extensive trial-and-error with undocumented class locations and missing transitive dependencies.
**Decision:** Pivot to **Spring Boot 3.5.11** (latest 3.5.x patch). This is the battle-tested line with OSS support through June 2026. Since this is a greenfield project, migrating to 4.x later (once the ecosystem and documentation have matured) is straightforward.
**Impact:** None on architecture or feature scope. The hexagonal package structure, dependency choices, and all other decisions remain unchanged. Only the Spring Boot parent version in `pom.xml` changed.
### Updated Target Repository Layout
```
fete/
├── backend/
│ ├── pom.xml
│ ├── mvnw
│ ├── mvnw.cmd
│ ├── .mvn/wrapper/maven-wrapper.properties
│ └── src/
│ ├── main/
│ │ ├── java/de/fete/
│ │ │ ├── FeteApplication.java
│ │ │ ├── domain/model/ (package-info.java)
│ │ │ ├── domain/port/in/ (package-info.java)
│ │ │ ├── domain/port/out/ (package-info.java)
│ │ │ ├── application/service/ (package-info.java)
│ │ │ ├── adapter/in/web/ (package-info.java)
│ │ │ ├── adapter/out/persistence/(package-info.java)
│ │ │ └── config/ (package-info.java)
│ │ └── resources/
│ │ └── application.properties
│ └── test/java/de/fete/
│ └── FeteApplicationTest.java
├── frontend/
│ ├── public/
│ ├── src/
│ │ ├── assets/
│ │ ├── components/
│ │ ├── composables/
│ │ ├── router/index.ts
│ │ ├── views/
│ │ ├── App.vue
│ │ └── main.ts
│ ├── index.html
│ ├── package.json
│ ├── package-lock.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── eslint.config.js
├── spec/
├── .gitignore (extended)
├── CLAUDE.md
├── README.md
├── LICENSE
└── Ideen.md
```

View File

@@ -0,0 +1,63 @@
# Feature Specification: Initialize Monorepo Structure
**Feature**: `001-monorepo-setup`
**Created**: 2026-03-06
**Status**: Implemented
**Source**: Migrated from spec/setup-tasks.md
> **Note**: This is a setup task (infrastructure), not a user-facing feature. It establishes the repository structure as a prerequisite for all subsequent development work.
## User Scenarios & Testing
### User Story 1 - Developer can scaffold and build the monorepo (Priority: P1)
A developer cloning the repository for the first time can build both the backend and frontend from a clean checkout with no source code beyond the scaffold.
**Why this priority**: Without a working monorepo structure, no further development or CI work is possible.
**Independent Test**: Clone the repository, run `./mvnw verify` in `backend/` and `npm run build` in `frontend/` — both must succeed against the empty scaffold.
**Acceptance Scenarios**:
1. **Given** a fresh clone of the repository, **When** the developer inspects the root, **Then** separate `backend/` and `frontend/` directories exist alongside shared top-level files (README, Dockerfile, CLAUDE.md, LICENSE, .gitignore).
2. **Given** the `backend/` directory, **When** the developer runs `./mvnw verify`, **Then** the build succeeds with no source code beyond the hexagonal/onion architecture scaffold using Java (latest LTS), Spring Boot, and Maven.
3. **Given** the `frontend/` directory, **When** the developer runs `npm run build`, **Then** the build succeeds with the Vue 3 + Vite + TypeScript + Vue Router scaffold.
4. **Given** the repository root, **When** the developer inspects `.gitignore`, **Then** build artifacts, IDE files, and dependency directories for both Java/Maven and Node/Vue are covered.
---
### Edge Cases
- What happens when a developer uses an older Java version? [NEEDS EXPANSION]
- How does the scaffold behave with no `.env` or environment variables set? [NEEDS EXPANSION]
## Requirements
### Functional Requirements
- **FR-001**: Repository MUST have a `backend/` directory containing a Java Spring Boot Maven project with hexagonal/onion architecture scaffold.
- **FR-002**: Repository MUST have a `frontend/` directory containing a Vue 3 project with Vite, TypeScript, and Vue Router.
- **FR-003**: Repository MUST include shared top-level files: README, Dockerfile, CLAUDE.md, LICENSE (GPL), and .gitignore.
- **FR-004**: Both projects MUST build successfully from an empty scaffold (no application source code required).
- **FR-005**: `.gitignore` MUST cover build artifacts, IDE files, and dependency directories for both Java/Maven and Node/Vue.
### Key Entities
- **Monorepo**: Single git repository containing both `backend/` and `frontend/` as separate projects sharing a root.
## Success Criteria
### Measurable Outcomes
- **SC-001**: `cd backend && ./mvnw verify` exits 0 on a clean checkout.
- **SC-002**: `cd frontend && npm run build` exits 0 on a clean checkout.
- **SC-003**: All six acceptance criteria are checked off (all complete — status: Implemented).
### Acceptance Criteria (original)
- [x] Single repository with `backend/` and `frontend/` directories
- [x] Backend: Java (latest LTS), Spring Boot, Maven, hexagonal/onion architecture scaffold
- [x] Frontend: Vue 3 with Vite as bundler, TypeScript, Vue Router
- [x] Shared top-level files: README, Dockerfile, CLAUDE.md, LICENSE (GPL), .gitignore
- [x] Both projects build successfully with no source code (empty scaffold)
- [x] .gitignore covers build artifacts, IDE files, and dependency directories for both Java/Maven and Node/Vue

View File

@@ -0,0 +1,366 @@
---
date: 2026-03-04T17:40:16+00:00
git_commit: 96ef8656bd87032696ae82198620d99f20d80d3b
branch: master
topic: "T-2: Docker Deployment Setup"
tags: [plan, docker, deployment, spring-boot, spa]
status: draft
---
# T-2: Docker Deployment Setup
## Overview
Create a multi-stage Dockerfile that builds both backend (Spring Boot) and frontend (Vue 3) and produces a single runnable container. Spring Boot serves the SPA's static files directly — one process, one port, one JAR. This requires migrating from `server.servlet.context-path=/api` to `addPathPrefix` and adding SPA forwarding so Vue Router's history mode works.
## Current State Analysis
**What exists:**
- Backend: Spring Boot 3.5.11, Java 25, Maven with wrapper, Actuator health endpoint
- Frontend: Vue 3, Vite, TypeScript, Vue Router (history mode), openapi-fetch client
- `server.servlet.context-path=/api` scopes everything under `/api` (including static resources)
- Health test at `backend/src/test/java/de/fete/FeteApplicationTest.java:27` uses `get("/actuator/health")`
- OpenAPI spec at `backend/src/main/resources/openapi/api.yaml` has `servers: [{url: /api}]`
- Frontend client at `frontend/src/api/client.ts` uses `baseUrl: "/api"`
- ArchUnit registers `de.fete.config..` as an adapter package — config classes belong there
- `package-lock.json` exists (needed for `npm ci`)
- Maven wrapper `.properties` present (JAR downloads automatically)
- No Dockerfile, no `.dockerignore` exist yet
**What's missing:**
- Dockerfile (multi-stage build)
- `.dockerignore`
- `WebConfig` to replace context-path with `addPathPrefix`
- SPA forwarding config (so Vue Router history mode works when served by Spring Boot)
### Key Discoveries:
- `FeteApplicationTest.java:27`: MockMvc test uses `get("/actuator/health")` — path is relative to context path in MockMvc, so removing context-path does NOT break this test
- `api.yaml:11`: OpenAPI `servers: [{url: /api}]` — stays unchanged, `addPathPrefix` produces the same routing
- `client.ts:4`: `baseUrl: "/api"` — stays unchanged
- `HexagonalArchitectureTest.java:22`: `config` is an adapter in ArchUnit → new config classes go in `de.fete.config`
- `router/index.ts:5`: Uses `createWebHistory` → history mode, needs server-side SPA forwarding
## Desired End State
After implementation:
1. `docker build -t fete .` succeeds at the repo root
2. `docker run -p 8080:8080 fete` starts the container
3. `curl http://localhost:8080/actuator/health` returns `{"status":"UP"}`
4. `curl http://localhost:8080/` returns the Vue SPA's `index.html`
5. `curl http://localhost:8080/some/spa/route` also returns `index.html` (SPA forwarding)
6. `curl http://localhost:8080/assets/...` returns actual static files
7. All existing tests still pass (`./mvnw test` and `npm run test:unit`)
## What We're NOT Doing
- Database wiring (`DATABASE_URL`, JPA, Flyway) — deferred to T-4
- docker-compose example — deferred to T-4
- Environment variable configuration (Unsplash key, max events) — deferred to T-4
- README deployment documentation — deferred to T-4
- Production hardening (JVM flags, memory limits, graceful shutdown) — not in scope
- TLS/HTTPS — hoster's responsibility (reverse proxy)
- gzip compression config — premature optimization at this scale
## Implementation Approach
Three phases, strictly sequential: first the Spring Boot config changes (testable locally without Docker), then the Dockerfile and `.dockerignore`, then end-to-end verification.
---
## Phase 1: Spring Boot Configuration Changes
### Overview
Replace `context-path=/api` with `addPathPrefix` so that API endpoints live under `/api/*` but static resources and SPA routes are served at `/`. Add SPA forwarding so non-API, non-static requests return `index.html`.
### Changes Required:
#### [x] 1. Remove context-path from application.properties
**File**: `backend/src/main/resources/application.properties`
**Changes**: Remove `server.servlet.context-path=/api`. Explicitly set Actuator base path to keep it outside `/api`.
```properties
spring.application.name=fete
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never
```
Note: Actuator defaults to `/actuator` which is exactly where we want it (outside `/api`). No extra config needed.
#### [x] 2. Create WebConfig with addPathPrefix
**File**: `backend/src/main/java/de/fete/config/WebConfig.java`
**Changes**: New `@Configuration` class that prefixes only `@RestController` handlers with `/api`.
```java
package de.fete.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.RestController;
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.resource.PathResourceResolver;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import java.io.IOException;
/** Configures API path prefix and SPA static resource serving. */
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
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);
return (requested.exists() && requested.isReadable())
? requested
: new ClassPathResource("/static/index.html");
}
});
}
}
```
This single class handles both concerns:
- `configurePathMatch`: prefixes `@RestController` endpoints with `/api`
- `addResourceHandlers`: serves static files from `classpath:/static/`, falls back to `index.html` for SPA routes
The SPA forwarding only activates when `classpath:/static/index.html` exists (i.e. in the Docker image where the frontend is bundled). During local backend development without frontend assets, requests to `/` will simply 404 as before — no behavior change for the dev workflow.
#### [x] 3. Write test for WebConfig behavior
**File**: `backend/src/test/java/de/fete/config/WebConfigTest.java`
**Changes**: New test class verifying the `/api` prefix routing and actuator independence.
```java
package de.fete.config;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
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.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class WebConfigTest {
@Autowired
private MockMvc mockMvc;
@Test
void actuatorHealthIsOutsideApiPrefix() throws Exception {
mockMvc.perform(get("/actuator/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"));
}
@Test
void apiPrefixNotAccessibleWithoutIt() throws Exception {
// /health without /api prefix should not resolve to the API endpoint
mockMvc.perform(get("/health"))
.andExpect(status().isNotFound());
}
}
```
Note: The existing `FeteApplicationTest.healthEndpointReturns200()` already tests `/actuator/health`. Since we're removing context-path but MockMvc paths are relative to the servlet context anyway, the existing test continues to pass without changes. The new test adds explicit verification that the `/api` prefix isolation works correctly.
### Success Criteria:
#### Automated Verification:
- [x] `cd backend && ./mvnw test` — all tests pass (existing + new)
- [x] `cd backend && ./mvnw checkstyle:check` — no style violations
#### Manual Verification:
- [x] `cd backend && ./mvnw spring-boot:run`, then `curl http://localhost:8080/actuator/health` returns `{"status":"UP"}`
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Phase 2: Dockerfile and .dockerignore
### Overview
Create the multi-stage Dockerfile (frontend build → backend build with static assets → JRE runtime) and a `.dockerignore` to keep the build context clean.
### Changes Required:
#### [x] 1. Create .dockerignore
**File**: `.dockerignore`
**Changes**: Exclude build artifacts, dependencies, IDE files, and dev-only files from the Docker build context.
```
# Build artifacts
**/target/
**/dist/
**/build/
# Dependencies (rebuilt inside Docker)
**/node_modules/
# IDE
.idea/
.vscode/
**/*.iml
# Git
.git/
.gitignore
# CI/CD
.gitea/
# Agent/dev files
.claude/
.ralph/
.rodney/
.agent-tests/
docs/
spec/
# OS files
.DS_Store
Thumbs.db
# Environment
.env
.env.*
# Generated files (rebuilt in Docker)
frontend/src/api/schema.d.ts
```
#### [x] 2. Create multi-stage Dockerfile
**File**: `Dockerfile`
**Changes**: Three-stage build — frontend, backend, runtime.
```dockerfile
# Stage 1: Build frontend
FROM node:24-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# OpenAPI spec needed for type generation (npm run build runs generate:api)
COPY backend/src/main/resources/openapi/api.yaml \
../backend/src/main/resources/openapi/api.yaml
RUN npm run build
# Stage 2: Build backend with frontend assets baked in
FROM eclipse-temurin:25-jdk-alpine AS backend-build
WORKDIR /app/backend
COPY backend/ ./
COPY --from=frontend-build /app/frontend/dist src/main/resources/static/
RUN ./mvnw -B -DskipTests -Dcheckstyle.skip -Dspotbugs.skip package
# Stage 3: Runtime
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
COPY --from=backend-build /app/backend/target/*.jar app.jar
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
```
Design decisions:
- **Frontend first**: `npm run build` triggers `generate:api` which needs `api.yaml` — copied from the build context
- **Layer caching**: `npm ci` before `COPY frontend/` so dependency install is cached unless `package*.json` changes
- **`-DskipTests -Dcheckstyle.skip -Dspotbugs.skip`**: Tests run in CI (T-3), not during image build
- **JRE-only runtime**: No JDK, no Node, no Maven in the final image
- **Alpine images**: Minimal size
- **HEALTHCHECK directive**: Docker-native health checking via Actuator
Note on Java 25: `eclipse-temurin:25-jdk-alpine` / `eclipse-temurin:25-jre-alpine` may not yet be published. If unavailable, fall back to `eclipse-temurin:21-jdk-alpine` / `eclipse-temurin:21-jre-alpine` (current LTS). The Java version in the Dockerfile does not need to match the development Java version exactly — Spring Boot 3.5.x runs on both 21 and 25.
### Success Criteria:
#### Automated Verification:
- [x] `docker build -t fete .` succeeds without errors
#### Manual Verification:
- [x] `docker run --rm -p 8080:8080 fete` starts the container
- [x] `curl http://localhost:8080/actuator/health` returns `{"status":"UP"}`
- [x] `curl http://localhost:8080/` returns HTML (the Vue SPA's index.html)
- [x] `docker images fete --format '{{.Size}}'` shows a reasonable size (< 400MB)
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Phase 3: Finalize and Verify
### Overview
Run all existing tests to confirm no regressions, check off T-2 acceptance criteria in the spec, and commit.
### Changes Required:
#### [x] 1. Check off T-2 acceptance criteria
**File**: `spec/setup-tasks.md`
**Changes**: Mark completed acceptance criteria for T-2.
#### [x] 2. Commit the research report
**File**: `docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md`
**Changes**: Stage and commit the already-written research report alongside the implementation.
### Success Criteria:
#### Automated Verification:
- [x] `cd backend && ./mvnw test` — all tests pass
- [x] `cd frontend && npm run test:unit -- --run` — all tests pass
- [x] `cd backend && ./mvnw verify` — full verification including SpotBugs
- [x] `docker build -t fete .` — still builds cleanly
#### Manual Verification:
- [x] Container starts and health check responds
- [x] SPA is accessible at `http://localhost:8080/`
- [x] All T-2 acceptance criteria in `spec/setup-tasks.md` are checked off
---
## Testing Strategy
### Unit Tests:
- `WebConfigTest.actuatorHealthIsOutsideApiPrefix()` — actuator accessible at `/actuator/health`
- `WebConfigTest.apiPrefixNotAccessibleWithoutIt()``/health` without prefix returns 404
- Existing `FeteApplicationTest.healthEndpointReturns200()` — regression test (unchanged)
### Integration Tests:
- Docker build succeeds end-to-end
- Container starts and serves both API and static content
### Manual Testing Steps:
1. `docker build -t fete .`
2. `docker run --rm -p 8080:8080 fete`
3. Open `http://localhost:8080/` in browser — should see Vue SPA
4. Open `http://localhost:8080/actuator/health` — should see `{"status":"UP"}`
5. Navigate to a non-existent SPA route like `http://localhost:8080/events/test` — should still see Vue SPA (SPA forwarding)
## Migration Notes
- `server.servlet.context-path=/api` is removed → any external clients that previously called `http://host:8080/api/actuator/health` must now call `http://host:8080/actuator/health`. Since the app is not yet deployed, this has zero impact.
- The OpenAPI spec's `servers: [{url: /api}]` is unchanged. The `addPathPrefix` produces identical routing to the old context-path for all `@RestController` endpoints.
## References
- Research report: `docs/agents/research/2026-03-04-spa-springboot-docker-patterns.md`
- T-2 spec: `spec/setup-tasks.md` (lines 24-37)
- Existing health test: `backend/src/test/java/de/fete/FeteApplicationTest.java:26-30`
- ArchUnit config adapter: `backend/src/test/java/de/fete/HexagonalArchitectureTest.java:22`
- Spring PathMatchConfigurer docs: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/path-matching.html

View File

@@ -0,0 +1,135 @@
# Single-Container Deployment: Spring Boot + Vue SPA
**Date:** 2026-03-04
**Context:** T-2 research — how to serve a Spring Boot API and Vue SPA from one Docker container.
## The Three Approaches
### Approach A: Spring Boot Serves Static Files (Recommended for fete)
Frontend `dist/` is built by Vite and copied into Spring Boot's `classpath:/static/` during the Docker build. One JAR serves everything.
**Pros:** One process, one port, one health check. No process manager. The JAR is the single artifact.
**Cons:** Embedded Tomcat is not optimized for static files (no `sendfile`, no `gzip_static`). Irrelevant at fete's scale (tens to hundreds of users).
**Real-world examples:** [jonashackt/spring-boot-vuejs](https://github.com/jonashackt/spring-boot-vuejs), [georgwittberger/spring-boot-embedded-spa-example](https://github.com/georgwittberger/spring-boot-embedded-spa-example), [bootify-io/spring-boot-react-example](https://github.com/bootify-io/spring-boot-react-example).
### Approach B: nginx + Spring Boot (Two Processes)
nginx serves static files, proxies `/api` to Spring Boot. Needs supervisord or a wrapper script.
**Pros:** nginx is battle-tested for static files (gzip, caching, HTTP/2). Clean separation.
**Cons:** Two processes, supervisord complexity, partial-failure detection issues, larger image.
**When to use:** High-traffic apps where static file performance matters.
### Approach C: Separate Containers
Not relevant — violates the single-container requirement.
## The Context-Path Problem
Current config: `server.servlet.context-path=/api`. This scopes **everything** under `/api`, including static resources. Frontend at `classpath:/static/` would be served at `/api/index.html`, not `/`.
### Solution: Remove context-path, use `addPathPrefix`
Remove `server.servlet.context-path=/api`. Instead, use Spring's `PathMatchConfigurer`:
```java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/api",
c -> c.isAnnotationPresent(RestController.class));
}
}
```
Result:
- API: `/api/...` (only `@RestController` classes)
- Static files: `/` (from `classpath:/static/`)
- Actuator: `/actuator/health` (outside `/api` — correct for infra health checks)
- SPA forwarding controller (`@Controller`, not `@RestController`) is not prefixed
OpenAPI spec keeps `servers: [{url: /api}]`. Frontend client keeps `baseUrl: "/api"`. No changes needed.
## SPA Routing (Vue Router History Mode)
Vue Router in history mode uses real URL paths. The server must return `index.html` for all paths that aren't API endpoints or actual static files.
### Recommended: Custom `PathResourceResolver`
```java
@Configuration
public class SpaConfig implements WebMvcConfigurer {
@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);
return (requested.exists() && requested.isReadable())
? requested
: new ClassPathResource("/static/index.html");
}
});
}
}
```
Requests flow: `@RestController` match → serve API. No match → resource handler → file exists → serve file. No file → serve `index.html` → Vue Router handles route.
## Multi-Stage Dockerfile Pattern
```dockerfile
# Stage 1: Build frontend
FROM node:24-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
COPY backend/src/main/resources/openapi/api.yaml ../backend/src/main/resources/openapi/api.yaml
RUN npm run build
# Stage 2: Build backend (with frontend assets)
FROM eclipse-temurin:25-jdk-alpine AS backend-build
WORKDIR /app
COPY backend/ backend/
COPY --from=frontend-build /app/frontend/dist backend/src/main/resources/static/
WORKDIR /app/backend
RUN ./mvnw -B -DskipTests package
# Stage 3: Runtime
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
COPY --from=backend-build /app/backend/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
```
Key decisions:
- Frontend built first (needs `api.yaml` for type generation)
- `npm ci` before `COPY frontend/` for Docker layer caching
- Frontend output copied into `backend/src/main/resources/static/` before Maven build → ends up in JAR
- `-DskipTests` in Docker build (tests run in CI)
- JRE-only final image (no JDK, no Node)
- Alpine-based for minimal size
## Open-Source Consensus
All surveyed Spring Boot + SPA projects use Approach A. None use supervisord + nginx for small-to-medium apps.
## Sources
- [jonashackt/spring-boot-vuejs](https://github.com/jonashackt/spring-boot-vuejs)
- [georgwittberger/spring-boot-embedded-spa-example](https://github.com/georgwittberger/spring-boot-embedded-spa-example)
- [Bundling React/Vite with Spring Boot (jessym.com)](https://www.jessym.com/articles/bundling-react-vite-with-spring-boot)
- [Spring Framework PathMatchConfigurer docs](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/path-matching.html)
- [Docker: Multi-service containers](https://docs.docker.com/engine/containers/multi-service_container/)

View File

@@ -0,0 +1,55 @@
# Feature Specification: Docker Deployment Setup
**Feature**: `002-docker-deployment`
**Created**: 2026-03-06
**Status**: Implemented
**Source**: Migrated from spec/setup-tasks.md
> Note: This is a setup task (infrastructure), not a user-facing feature.
## User Scenarios & Testing
### User Story 1 - Build and run the application as a single Docker container (Priority: P1)
A developer or operator can build the project with a single `docker build .` command and run it as a self-contained container. The multi-stage Dockerfile compiles backend and frontend in isolation and produces a minimal runnable image.
**Why this priority**: Docker packaging is the primary deployment mechanism. Without it, no deployment can happen.
**Independent Test**: Run `docker build .` and then start the container. Verify the health-check endpoint responds.
**Acceptance Scenarios**:
1. **Given** the repository is checked out, **When** `docker build .` is executed, **Then** the build succeeds and produces a working image.
2. **Given** a built Docker image, **When** a container is started from it, **Then** the health-check endpoint responds successfully.
3. **Given** the repository is checked out, **When** a `.dockerignore` file is present, **Then** build artifacts, IDE files, and unnecessary files are excluded from the build context.
### Edge Cases
- What happens when the frontend build fails inside the Docker build? The multi-stage build must fail visibly so the broken image is never produced.
- What happens if the health-check endpoint is not reachable? Docker and orchestrators mark the container as unhealthy.
## Requirements
### Functional Requirements
- **FR-001**: A multi-stage Dockerfile MUST exist at the repo root that builds both backend and frontend and produces a single runnable container.
- **FR-002**: The Dockerfile MUST use separate build stages so backend and frontend build dependencies are not included in the final image.
- **FR-003**: A `.dockerignore` file MUST exclude build artifacts, IDE files, and unnecessary files from the build context.
- **FR-004**: The container MUST expose a health-check endpoint so Docker and orchestrators can verify the app is alive.
- **FR-005**: `docker build .` MUST succeed and produce a working image.
- **FR-006**: A started container MUST respond successfully to the health-check endpoint.
> Scope note (Addendum 2026-03-04): Database connectivity (`DATABASE_URL`), runtime environment variable configuration (Unsplash API key, max active events), and README docker-compose documentation are out of scope for T-2. They are deferred to T-4 where JPA and Flyway are introduced and can be tested end-to-end.
### Key Entities
- **Dockerfile**: Multi-stage build definition at the repo root. Stages: frontend build (Node), backend build (Maven/Java), final runtime image.
- **.dockerignore**: Excludes `target/`, `node_modules/`, IDE directories, and other non-essential files.
## Success Criteria
### Measurable Outcomes
- **SC-001**: `docker build .` completes without errors from a clean checkout.
- **SC-002**: A container started from the built image responds to the health-check endpoint within the configured timeout.
- **SC-003**: The final Docker image does not contain Maven, Node.js, or build tool binaries (multi-stage isolation verified).

View File

@@ -0,0 +1,266 @@
---
date: "2026-03-04T18:46:12.266203+00:00"
git_commit: 316137bf1c391577e884ce525af780f45e34da86
branch: master
topic: "T-3: CI/CD Pipeline — Gitea Actions"
tags: [plan, ci-cd, gitea, docker, buildah]
status: implemented
---
# T-3: CI/CD Pipeline Implementation Plan
## Overview
Set up a Gitea Actions CI/CD pipeline that runs on every push (tests only) and on SemVer-tagged releases (tests + build + publish). Single workflow file, three jobs.
## Current State Analysis
- All dependencies completed: T-1 (monorepo), T-2 (Dockerfile), T-5 (API-first tooling)
- Multi-stage Dockerfile exists and works (`Dockerfile:1-26`)
- Dockerfile skips tests (`-DskipTests -Dcheckstyle.skip -Dspotbugs.skip`) by design — quality gates belong in CI
- `.dockerignore` already excludes `.gitea/`
- No CI/CD configuration exists yet
- Backend has full verify lifecycle: Checkstyle, JUnit 5, ArchUnit, SpotBugs
- Frontend has lint (oxlint + ESLint), type-check (`vue-tsc`), tests (Vitest), and Vite production build
## Desired End State
A single workflow file at `.gitea/workflows/ci.yaml` that:
1. Triggers on every push (branches and tags)
2. Runs backend and frontend quality gates in parallel
3. On SemVer tags only: builds the Docker image via Buildah and publishes it with rolling tags
The Docker image build is **not** run on regular pushes. All quality gates (tests, lint, type-check, production build) already run in the test jobs. The Dockerfile itself changes rarely, and any breakage surfaces at the next tagged release. This avoids a redundant image build on every push.
### Verification:
- Push a commit → pipeline runs tests only, no image build
- Push a non-SemVer tag → same behavior
- Push a SemVer tag (e.g. `1.0.0`) → tests + build + publish with 4 tags
### Pipeline Flow:
```
git push (any branch/tag)
┌───────────┴───────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────────┐
│ backend-test │ │ frontend-test │
│ │ │ │
│ JDK 25 │ │ Node 24 │
│ ./mvnw -B │ │ npm ci │
│ verify │ │ npm run lint │
│ │ │ npm run type-check │
│ │ │ npm run test:unit │
│ │ │ -- --run │
│ │ │ npm run build │
└────────┬─────────┘ └──────────┬───────────┘
│ │
└───────────┬─────────────┘
┌───────────────────┐
│ SemVer-Tag? │── nein ──► DONE
└────────┬──────────┘
│ ja
┌─────────────────────┐
│ build-and-publish │
│ │
│ buildah bud │
│ buildah tag ×4 │
│ buildah push ×4 │
└─────────────────────┘
```
## What We're NOT Doing
- No deployment automation (how/where to deploy is the hoster's responsibility)
- No branch protection rules (Gitea admin concern, not pipeline scope)
- No automated versioning (no release-please, no semantic-release — manual `git tag`)
- No caching optimization (can be added later if runner time becomes a concern)
- No separate staging/production pipelines
- No notifications (Slack, email, etc.)
## Implementation Approach
Single phase — this is one YAML file with well-defined structure. The workflow uses Buildah for image builds to avoid Docker-in-Docker issues on the self-hosted runner.
## Phase 1: Gitea Actions Workflow
### Overview
Create the complete CI/CD workflow file with three jobs: backend-test, frontend-test, build-and-publish (SemVer tags only).
### Changes Required:
#### [x] 1. Create workflow directory
**Action**: `mkdir -p .gitea/workflows/`
#### [x] 2. Explore OpenAPI spec access in CI
The `npm run type-check` and `npm run build` steps need access to the OpenAPI spec because `generate:api` runs as a pre-step. In CI, the checkout includes both `backend/` and `frontend/` as sibling directories, so the relative path `../backend/src/main/resources/openapi/api.yaml` from `frontend/` should resolve correctly.
**Task:** During implementation, verify whether the relative path works from the CI checkout structure. If it does, no `cp` step is needed. If it doesn't, add an explicit copy step. The workflow YAML below includes a `cp` as a safety measure — **remove it if the relative path works without it**.
**Resolution:** The relative path works. The `cp` was removed. An explicit `generate:api` step was added before `type-check` so that `schema.d.ts` exists when `vue-tsc` runs (since `type-check` alone doesn't trigger code generation).
#### [x] 3. Create workflow file
**File**: `.gitea/workflows/ci.yaml`
```yaml
name: CI
on:
push:
jobs:
backend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 25
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 25
- name: Run backend verify
run: cd backend && ./mvnw -B verify
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node 24
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install dependencies
run: cd frontend && npm ci
- name: Lint
run: cd frontend && npm run lint
- name: Type check
run: |
cp backend/src/main/resources/openapi/api.yaml frontend/
cd frontend && npm run type-check
- name: Unit tests
run: cd frontend && npm run test:unit -- --run
- name: Production build
run: cd frontend && npm run build
build-and-publish:
needs: [backend-test, frontend-test]
if: startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, '.')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Parse SemVer tag
id: semver
run: |
TAG="${{ github.ref_name }}"
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Not a valid SemVer tag: $TAG"
exit 1
fi
MAJOR="${TAG%%.*}"
MINOR="${TAG%.*}"
echo "full=$TAG" >> "$GITHUB_OUTPUT"
echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
- name: Build image
run: |
REGISTRY="${{ github.server_url }}"
REGISTRY="${REGISTRY#https://}"
REGISTRY="${REGISTRY#http://}"
REPO="${{ github.repository }}"
IMAGE="${REGISTRY}/${REPO}"
buildah bud -t "${IMAGE}:${{ steps.semver.outputs.full }}" .
buildah tag "${IMAGE}:${{ steps.semver.outputs.full }}" \
"${IMAGE}:${{ steps.semver.outputs.minor }}" \
"${IMAGE}:${{ steps.semver.outputs.major }}" \
"${IMAGE}:latest"
echo "IMAGE=${IMAGE}" >> "$GITHUB_ENV"
- name: Push to registry
run: |
buildah login -u "${{ github.repository_owner }}" \
-p "${{ secrets.REGISTRY_TOKEN }}" \
"${IMAGE%%/*}"
buildah push "${IMAGE}:${{ steps.semver.outputs.full }}"
buildah push "${IMAGE}:${{ steps.semver.outputs.minor }}"
buildah push "${IMAGE}:${{ steps.semver.outputs.major }}"
buildah push "${IMAGE}:latest"
```
### Success Criteria:
#### Automated Verification:
- [x] YAML is valid: `python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/ci.yaml'))"`
- [x] File is in the correct directory: `.gitea/workflows/ci.yaml`
- [x] Workflow triggers on `push` (all branches and tags)
- [x] `backend-test` uses JDK 25 and runs `./mvnw -B verify`
- [x] `frontend-test` uses Node 24 and runs lint, type-check, tests, and build
- [x] `build-and-publish` depends on both test jobs (`needs: [backend-test, frontend-test]`)
- [x] `build-and-publish` only runs on SemVer tags (`if` condition)
- [x] SemVer parsing correctly extracts major, minor, and full version
- [x] Registry URL is derived from `github.server_url` with protocol stripped
- [x] Authentication uses `secrets.REGISTRY_TOKEN` (not the built-in token)
#### Manual Verification:
- [x] Push a commit to a branch → pipeline runs `backend-test` and `frontend-test` only — no image build
- [x] Push a SemVer tag → pipeline runs all three jobs, image appears in Gitea container registry with 4 tags
- [ ] Break a test intentionally → pipeline fails, `build-and-publish` does not run (skipped — guaranteed by `needs` dependency, verified implicitly)
- [x] Push a non-SemVer tag → pipeline runs tests only, no image build
**Implementation Note**: After creating the workflow file and passing automated verification, the manual verification requires pushing to the actual Gitea instance. Pause here for the human to test on the real runner.
---
## Testing Strategy
### Automated (pre-push):
- YAML syntax validation
- Verify file structure and job dependencies match the spec
### Manual (post-push, on Gitea):
1. Push a normal commit → verify only test jobs run, no image build
2. Intentionally break a backend test → verify pipeline fails at `backend-test`
3. Intentionally break a frontend lint rule → verify pipeline fails at `frontend-test`
4. Fix and push a SemVer tag (e.g. `0.1.0`) → verify all 3 jobs run, image published with rolling tags
5. Verify image is pullable: `docker pull {registry}/{owner}/fete:0.1.0`
## Performance Considerations
- Backend and frontend tests run in parallel (separate jobs) — this is the main time saver
- Docker image build only runs on SemVer tags — no wasted runner time on regular pushes
- No Maven/npm caching configured — can be added later if runner time becomes a problem
## Configuration Prerequisites
The following must be configured in Gitea **before** the pipeline can publish images:
1. **Repository secret** `REGISTRY_TOKEN`: A Gitea Personal Access Token with `package:write` permission
2. **Docker** must be available on the runner (act_runner provides this via socket forwarding)
### Addendum: Buildah → Docker pivot
Buildah was the original choice to avoid Docker-in-Docker issues. However, the act_runner does not have Buildah installed, and running it inside a container would require elevated privileges. Since the runner already has Docker available via socket forwarding, the workflow was switched to `docker build/tag/push`. This is not classic DinD — it uses the host Docker daemon directly.
## References
- Research: `docs/agents/research/2026-03-04-t3-cicd-pipeline.md`
- Spec: `spec/setup-tasks.md:41-55`
- Dockerfile: `Dockerfile:1-26`
- Frontend scripts: `frontend/package.json:6-17`
- Backend plugins: `backend/pom.xml:56-168`

View File

@@ -0,0 +1,213 @@
---
date: "2026-03-04T18:19:10.241698+00:00"
git_commit: 316137bf1c391577e884ce525af780f45e34da86
branch: master
topic: "T-3: CI/CD Pipeline — Gitea Actions"
tags: [research, codebase, ci-cd, gitea, docker, pipeline]
status: complete
---
# Research: T-3 CI/CD Pipeline
## Research Question
What is the current state of the project relevant to implementing T-3 (CI/CD pipeline with Gitea Actions), what are its requirements, dependencies, and what infrastructure already exists?
## Summary
T-3 requires a Gitea Actions workflow in `.gitea/workflows/` that runs on every push: tests both backend and frontend, builds a Docker image, and publishes it to the Gitea container registry. The task is currently unstarted but all dependencies (T-1, T-2) are completed. The project already has a working multi-stage Dockerfile, comprehensive test suites for both backend and frontend, and clearly defined build commands. No CI/CD configuration files exist yet.
## Detailed Findings
### T-3 Specification
Defined in `spec/setup-tasks.md:41-55`.
**Acceptance Criteria (all unchecked):**
1. Gitea Actions workflow file in `.gitea/workflows/` runs on push: test, build, publish Docker image
2. Backend tests run via Maven
3. Frontend tests run via Vitest
4. Docker image is published to the Gitea container registry on the same instance
5. Pipeline fails visibly if any test fails or the build breaks
6. Docker image is only published if all tests pass and the build succeeds
**Dependencies:** T-1 (completed), T-2 (completed)
**Platform Decision (Q-5):** Per `.ralph/review-findings/questions.md:12-22`, Gitea is confirmed as the exclusive CI/CD platform. Only Gitea infrastructure will be used — Gitea Actions for pipelines, Gitea container registry for Docker image publishing.
### Dependencies — What Already Exists
#### Dockerfile (repo root)
A working 3-stage Dockerfile exists (`Dockerfile:1-26`):
| Stage | Base Image | Purpose |
|-------|-----------|---------|
| `frontend-build` | `node:24-alpine` | `npm ci` + `npm run build` (includes OpenAPI type generation) |
| `backend-build` | `eclipse-temurin:25-jdk-alpine` | Maven build with frontend assets baked into `static/` |
| `runtime` | `eclipse-temurin:25-jre-alpine` | `java -jar app.jar`, exposes 8080, HEALTHCHECK configured |
The Dockerfile skips tests and static analysis during build (`-DskipTests -Dcheckstyle.skip -Dspotbugs.skip` at `Dockerfile:17`), with the explicit design rationale that quality gates belong in CI (T-3).
The OpenAPI spec is copied into the frontend build stage (`Dockerfile:8-9`) because `npm run build` triggers `generate:api` as a pre-step.
#### .dockerignore
`.dockerignore:18-19` already excludes `.gitea/` from the Docker build context, anticipating this directory's creation.
#### Backend Build & Test
- **Build:** `./mvnw package` (or `./mvnw -B package` for batch mode)
- **Test:** `./mvnw test`
- **Full verify:** `./mvnw verify` (includes Checkstyle, SpotBugs, ArchUnit, JUnit 5)
- **Config:** `backend/pom.xml` — Spring Boot 3.5.11, Java 25
- **Quality gates:** Checkstyle (Google Style, `validate` phase), SpotBugs (`verify` phase), ArchUnit (9 rules, runs with JUnit), Surefire (fail-fast at 1 failure)
#### Frontend Build & Test
- **Build:** `npm run build` (runs `generate:api` + parallel type-check and vite build)
- **Test:** `npm run test:unit` (Vitest)
- **Lint:** `npm run lint` (oxlint + ESLint)
- **Type check:** `vue-tsc --build`
- **Config:** `frontend/package.json` — Vue 3.5, Vite 7.3, TypeScript 5.9
- **Node engines:** `^20.19.0 || >=22.12.0`
### Scheduling and Parallelism
Per `spec/implementation-phases.md:15`, T-3 is at priority level 4* — parallelizable with T-4 (Development infrastructure). It can be implemented at any time now since T-1 and T-2 are done.
From `review-findings.md:105-107`: T-3 should be completed before the first user story is finished, otherwise code will exist without a pipeline.
### Gitea Actions Specifics
Gitea Actions uses the same YAML format as GitHub Actions with minor differences:
- Workflow files go in `.gitea/workflows/` (not `.github/workflows/`)
- Uses `runs-on: ubuntu-latest` or custom runner labels
- Container registry URL pattern: `{gitea-host}/{owner}/{repo}` (no separate registry domain)
- Supports `docker/login-action`, `docker/build-push-action`, and direct `docker` CLI
- Secrets are configured in the Gitea repository settings (e.g., `secrets.GITHUB_TOKEN` equivalent is typically `secrets.GITEA_TOKEN` or the built-in `gitea.token`)
### Build Commands for the Pipeline
The pipeline needs to execute these commands in order:
| Step | Command | Runs in |
|------|---------|---------|
| Backend test | `cd backend && ./mvnw -B verify` | JDK 25 environment |
| Frontend install | `cd frontend && npm ci` | Node 24 environment |
| Frontend lint | `cd frontend && npm run lint` | Node 24 environment |
| Frontend type-check | `cd frontend && npm run type-check` | Node 24 environment (needs OpenAPI spec) |
| Frontend test | `cd frontend && npm run test:unit -- --run` | Node 24 environment |
| Docker build | `docker build -t {registry}/{owner}/{repo}:{tag} .` | Docker-capable runner |
| Docker push | `docker push {registry}/{owner}/{repo}:{tag}` | Docker-capable runner |
Note: `npm run build` is implicitly tested by the Docker build stage (the Dockerfile runs `npm run build` in stage 1). Running it separately in CI is redundant but could provide faster feedback.
The `--run` flag on Vitest ensures it runs once and exits (non-watch mode).
### Container Registry
The Gitea container registry is built into Gitea. Docker images are pushed using the Gitea instance hostname as the registry. Authentication uses a Gitea API token or the built-in `GITHUB_TOKEN`-equivalent that Gitea Actions provides.
Push format: `docker push {gitea-host}/{owner}/{repo}:{tag}`
### What Does NOT Exist Yet
- No `.gitea/` directory
- No `.github/` directory
- No workflow YAML files anywhere
- No CI/CD configuration of any kind
- No Makefile or build orchestration script
- No documentation about the Gitea instance URL or registry configuration
## Code References
- `spec/setup-tasks.md:41-55` — T-3 specification and acceptance criteria
- `spec/implementation-phases.md:15` — T-3 scheduling (parallel with T-4)
- `.ralph/review-findings/questions.md:12-22` — Q-5 resolution (Gitea confirmed)
- `Dockerfile:1-26` — Multi-stage Docker build
- `Dockerfile:17` — Tests skipped in Docker build (deferred to CI)
- `.dockerignore:18-19``.gitea/` already excluded from Docker context
- `backend/pom.xml:56-168` — Maven build plugins (Checkstyle, Surefire, SpotBugs, OpenAPI generator)
- `frontend/package.json:6-17` — npm build and test scripts
- `CLAUDE.md` — Build commands reference table
## Architecture Documentation
### Pipeline Architecture Pattern
The project follows a "test in CI, skip in Docker" pattern:
- The Dockerfile is a pure build artifact — it produces a runnable image as fast as possible
- All quality gates (tests, linting, static analysis) are expected to run in the CI pipeline before the Docker build
- The Docker image is only published if all preceding steps pass
### Gitea Actions Workflow Pattern (GitHub Actions compatible)
Gitea Actions workflows follow the same `on/jobs/steps` YAML structure as GitHub Actions. The runner is a self-hosted instance with Docker available on the host, but the pipeline uses Buildah for container image builds to avoid Docker-in-Docker complexity.
### Image Tagging Strategy (resolved)
SemVer with rolling tags, following standard Docker convention. When a tag like `2.3.9` is pushed, the pipeline publishes the **same image** under all four tags:
| Tag | Type | Example | Updated when |
|-----|------|---------|-------------|
| `2.3.9` | Immutable | Exact version | Only once, on this release |
| `2.3` | Rolling | Latest `2.3.x` | Overwritten by any `2.3.x` release |
| `2` | Rolling | Latest `2.x.x` | Overwritten by any `2.x.x` release |
| `latest` | Rolling | Newest release | Overwritten by every release |
This means the pipeline does four pushes per release (one per tag). Users can pin `2` to get automatic minor and patch updates, or pin `2.3.9` for exact reproducibility.
Images are only published on tagged releases (Git tags matching a SemVer pattern), not on every push.
### Release Process (resolved)
Manual Git tags trigger releases. The workflow is:
```bash
git tag 1.2.3
git push --tags
```
The pipeline triggers on tags matching a SemVer pattern (e.g., `1.2.3`) and publishes the image with rolling SemVer tags. No `v` prefix — tags are pure SemVer. No automated versioning tools (release-please, semantic-release) — the developer decides the version.
### Container Build Tool (resolved)
Buildah is used instead of Docker for building and pushing container images. This avoids Docker-in-Docker issues entirely and works cleanly on self-hosted runners regardless of whether the runner process runs inside a container or on the host.
### Pipeline Quality Scope (resolved)
The pipeline runs the maximum quality gates available:
- **Backend:** `./mvnw verify` (full lifecycle — Checkstyle, JUnit 5, ArchUnit, SpotBugs)
- **Frontend:** Lint (`npm run lint`), type-check (`npm run type-check`), tests (`npm run test:unit -- --run`)
### Gitea Registry Authentication (researched)
Key findings from Gitea documentation and community:
- `GITHUB_TOKEN` / `GITEA_TOKEN` (the built-in runner token) does **not** have permissions to push to the Gitea container registry. It only has repository read access.
- A **Personal Access Token** (PAT) with package write permissions must be created and stored as a repository secret (e.g., `REGISTRY_TOKEN`).
- The registry URL is the Gitea instance hostname (e.g., `gitea.example.com`). `${{ github.server_url }}` provides it with protocol prefix — needs stripping or use a repository variable.
- Push URL format: `{registry-host}/{owner}/{repo}:{tag}`
### Reference Workflow (existing project)
An existing Gitea Actions workflow in the sibling project `../arr/.gitea/workflows/deploy.yaml` provides a reference:
- Runner label: `ubuntu-latest`
- Uses `${{ vars.DEPLOY_PATH }}` for repository variables
- Simple deploy pattern (git pull + docker compose up)
## Resolved Questions
1. **Gitea instance URL:** Configured via repository variable or derived from `${{ github.server_url }}`. The registry hostname is the same as the Gitea instance.
2. **Runner label:** `ubuntu-latest` (consistent with the existing `arr` project workflow).
3. **Runner setup:** Self-hosted runner on the host with Docker available. Buildah used for image builds to avoid DinD.
4. **Container build tool:** Buildah (no DinD needed).
5. **Image tagging strategy:** SemVer with rolling tags (`latest`, `2`, `2.3`, `2.3.9`). Published only on tagged releases.
6. **Branch protection / publish trigger:** Images are only published from tagged releases, not from branch pushes. Every push triggers test + build (without publish).
7. **Maven lifecycle scope:** `./mvnw verify` (full lifecycle including SpotBugs). Frontend also runs all available quality gates (lint, type-check, tests).
8. **Registry authentication:** Personal Access Token stored as repository secret (built-in `GITHUB_TOKEN` lacks package write permissions).
## Open Questions
None — all questions resolved.

View File

@@ -0,0 +1,55 @@
# Feature Specification: CI/CD Pipeline
**Feature**: `003-cicd-pipeline`
**Created**: 2026-03-06
**Status**: Implemented
**Source**: Migrated from spec/setup-tasks.md
> Note: This is a setup task (infrastructure), not a user-facing feature. It describes the CI/CD pipeline that validates every push to the repository.
## User Scenarios & Testing
### Setup Task T-3 — CI/CD Pipeline (Priority: P0)
Set up a Gitea Actions CI/CD pipeline that runs on every push, ensuring code quality before deployment.
**Acceptance Scenarios**:
1. **Given** a push is made to the repository, **When** the Gitea Actions workflow triggers, **Then** backend tests run via Maven, frontend tests run via Vitest, and the Docker image is built.
2. **Given** all tests pass and the build succeeds, **When** the pipeline completes, **Then** the Docker image is published to the Gitea container registry.
3. **Given** any test fails or the build breaks, **When** the pipeline runs, **Then** it fails visibly and the Docker image is not published.
4. **Given** the workflow file exists, **When** inspected, **Then** it is located in `.gitea/workflows/` and runs on push.
### Edge Cases
- Pipeline must not publish the image if tests pass but the Docker build itself fails.
- Docker image is only published to the Gitea container registry on the same instance (no external registries).
## Requirements
### Functional Requirements
- **FR-T03-01**: Gitea Actions workflow file in `.gitea/workflows/` runs on push: test, build, publish Docker image.
- **FR-T03-02**: Backend tests run via Maven as part of the pipeline.
- **FR-T03-03**: Frontend tests run via Vitest as part of the pipeline.
- **FR-T03-04**: Docker image is published to the Gitea container registry on the same instance.
- **FR-T03-05**: Pipeline fails visibly if any test fails or the build breaks.
- **FR-T03-06**: Docker image is only published if all tests pass and the build succeeds.
### Notes
Per Q-5 resolution: the project uses Gitea as its hosting and CI/CD platform. The pipeline uses Gitea Actions (`.gitea/workflows/`) and publishes Docker images to the Gitea container registry. T-3 depends on T-1 (repository structure with both projects to test and build) and T-2 (Dockerfile used by the pipeline to build and publish the container image).
**Dependencies:** T-1, T-2
## Success Criteria
- [x] Gitea Actions workflow file in `.gitea/workflows/` runs on push: test, build, publish Docker image
- [x] Backend tests run via Maven
- [x] Frontend tests run via Vitest
- [x] Docker image is published to the Gitea container registry on the same instance
- [x] Pipeline fails visibly if any test fails or the build breaks
- [x] Docker image is only published if all tests pass and the build succeeds

View File

@@ -0,0 +1,567 @@
---
date: 2026-03-04T20:09:31.044992+00:00
git_commit: cb0bcad145b03fec63be0ee3c1fca46ee545329e
branch: master
topic: "T-4: Development Infrastructure Setup"
tags: [plan, database, liquibase, testcontainers, configuration, docker-compose]
status: draft
---
# T-4: Development Infrastructure Setup — Implementation Plan
## Overview
Set up the remaining development infrastructure needed before the first user story (US-1) can be implemented with TDD. This adds JPA + Liquibase for database migrations, PostgreSQL connectivity via environment variables, Testcontainers for integration tests, app-specific configuration properties, and README deployment documentation with a docker-compose example.
## Current State Analysis
**Already complete (no work needed):**
- SPA router: Vue Router with `createWebHistory`, backend SPA fallback in `WebConfig.java`
- Frontend test infrastructure: Vitest + `@vue/test-utils`, sample test passing
- Both test suites executable: `./mvnw test` (3 tests) and `npm run test:unit` (1 test)
**Missing (all work in this plan):**
- JPA, Liquibase, PostgreSQL driver — no database dependencies in `pom.xml`
- Testcontainers — not configured
- Database connectivity — no datasource properties
- App-specific config — no `@ConfigurationProperties`
- Profile separation — no `application-prod.properties`
- Deployment docs — no docker-compose in README
### Key Discoveries:
- `backend/pom.xml:1-170` — Spring Boot 3.5.11, no DB dependencies
- `backend/src/main/resources/application.properties:1-4` — Only app name + actuator
- `HexagonalArchitectureTest.java:22``config` is already an adapter in ArchUnit rules
- `FeteApplicationTest.java` — Uses `@SpringBootTest` + MockMvc; will need datasource after JPA is added
- `Dockerfile:26` — No `SPRING_PROFILES_ACTIVE` set
- `.gitignore:47-51``.env*` patterns exist but no `application-local.properties`
## Desired End State
After this plan is complete:
- `./mvnw test` runs all backend tests (including new Testcontainers-backed integration tests) against a real PostgreSQL without external setup
- `./mvnw spring-boot:run -Dspring-boot.run.profiles=local` starts the app against a local PostgreSQL
- Docker container starts with `DATABASE_URL`/`DATABASE_USERNAME`/`DATABASE_PASSWORD` env vars, runs Liquibase migrations, and responds to health checks
- README contains a copy-paste-ready docker-compose example for deployment
- `FeteProperties` scaffolds `fete.unsplash.api-key` and `fete.max-active-events` (no business logic yet)
### Verification:
- `cd backend && ./mvnw verify` — all tests green, checkstyle + spotbugs pass
- `cd frontend && npm run test:unit -- --run` — unchanged, still green
- `docker build .` — succeeds
- docker-compose (app + postgres) — container starts, `/actuator/health` returns `{"status":"UP"}`
## What We're NOT Doing
- No JPA entities or repositories — those come with US-1
- No domain model classes — those come with US-1
- No business logic for `FeteProperties` (Unsplash, max events) — US-13/US-16
- No standalone `docker-compose.yml` file in repo — inline in README per CLAUDE.md
- No `application-local.properties` committed — only the `.example` template
- No changes to frontend code — AC 4/6/7 are already met
---
## Phase 1: JPA + Liquibase + PostgreSQL Dependencies
### Overview
Add all database-related dependencies to `pom.xml`, create the Liquibase changelog structure with an empty baseline changeset, and update `application.properties` with JPA and Liquibase settings.
### Changes Required:
#### [x] 1. Add database dependencies to `pom.xml`
**File**: `backend/pom.xml`
**Changes**: Add four dependencies after the existing `spring-boot-starter-validation` block.
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
```
Add Testcontainers dependencies in test scope (after `archunit-junit5`):
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
```
Spring Boot's dependency management handles versions for all of these — no explicit version tags needed (except `archunit-junit5` which is already versioned).
#### [x] 2. Create Liquibase master changelog
**File**: `backend/src/main/resources/db/changelog/db.changelog-master.xml` (new)
**Changes**: Create the master changelog that includes individual changesets.
```xml
<?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">
<include file="db/changelog/000-baseline.xml"/>
</databaseChangeLog>
```
#### [x] 3. Create empty baseline changeset
**File**: `backend/src/main/resources/db/changelog/000-baseline.xml` (new)
**Changes**: Empty changeset that proves the tooling works. Liquibase creates its tracking tables (`databasechangelog`, `databasechangeloglock`) automatically.
```xml
<?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">
<!-- T-4: Baseline changeset. Proves Liquibase tooling works.
First real schema comes with US-1. -->
<changeSet id="000-baseline" author="fete">
<comment>Baseline changeset — Liquibase tooling verification</comment>
</changeSet>
</databaseChangeLog>
```
#### [x] 4. Update application.properties with JPA and Liquibase settings
**File**: `backend/src/main/resources/application.properties`
**Changes**: Add JPA and Liquibase configuration (environment-independent, always active).
```properties
spring.application.name=fete
# JPA
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
# Liquibase
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
# Actuator
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never
```
### Success Criteria:
#### Automated Verification:
- [ ] `cd backend && ./mvnw compile` succeeds (dependencies resolve, checkstyle passes)
- [ ] Changelog XML files are well-formed (Maven compile does not fail on resource processing)
#### Manual Verification:
- [ ] Verify `pom.xml` has all six new dependencies with correct scopes
- [ ] Verify changelog directory structure: `db/changelog/db.changelog-master.xml` includes `000-baseline.xml`
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Phase 2: Profile-Based Configuration and App Properties
### Overview
Create the profile-based property files for production and local development, add the `FeteProperties` configuration class, update `.gitignore`, and set the production profile in the Dockerfile.
### Changes Required:
#### [x] 1. Create production properties file
**File**: `backend/src/main/resources/application-prod.properties` (new)
**Changes**: Production profile with environment variable placeholders. Activated in Docker via `SPRING_PROFILES_ACTIVE=prod`.
```properties
# Database (required)
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
# App-specific (optional)
fete.unsplash.api-key=${UNSPLASH_API_KEY:}
fete.max-active-events=${MAX_ACTIVE_EVENTS:0}
```
#### [x] 2. Create local development properties template
**File**: `backend/src/main/resources/application-local.properties.example` (new)
**Changes**: Template that developers copy to `application-local.properties` (which is gitignored).
```properties
# Local development database
# Copy this file to application-local.properties and adjust as needed.
# Start with: ./mvnw spring-boot:run -Dspring-boot.run.profiles=local
spring.datasource.url=jdbc:postgresql://localhost:5432/fete
spring.datasource.username=fete
spring.datasource.password=fete
```
#### [x] 3. Add `application-local.properties` to `.gitignore`
**File**: `.gitignore`
**Changes**: Add the gitignore entry for the local properties file (under the Environment section).
```
# Spring Boot local profile (developer-specific, not committed)
backend/src/main/resources/application-local.properties
```
#### ~~4. Create `FeteProperties` configuration properties class~~ (deferred)
**File**: `backend/src/main/java/de/fete/config/FeteProperties.java` (new)
**Changes**: Type-safe configuration for app-specific settings. Both properties are only scaffolded — business logic comes with US-13/US-16.
```java
package de.fete.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Application-specific configuration properties.
*
* <p>Mapped from {@code fete.*} properties. Both properties are optional:
* <ul>
* <li>{@code fete.unsplash.api-key} — Unsplash API key (empty = feature disabled)
* <li>{@code fete.max-active-events} — Maximum active events (0 = unlimited)
* </ul>
*/
@ConfigurationProperties(prefix = "fete")
public class FeteProperties {
private final Unsplash unsplash;
private final int maxActiveEvents;
/** Creates FeteProperties with the given values. */
public FeteProperties(Unsplash unsplash, int maxActiveEvents) {
this.unsplash = unsplash != null ? unsplash : new Unsplash("");
this.maxActiveEvents = maxActiveEvents;
}
/** Returns the Unsplash configuration. */
public Unsplash getUnsplash() {
return unsplash;
}
/** Returns the maximum number of active events (0 = unlimited). */
public int getMaxActiveEvents() {
return maxActiveEvents;
}
/** Unsplash-related configuration. */
public record Unsplash(String apiKey) {
/** Creates Unsplash config with the given API key. */
public Unsplash {
if (apiKey == null) {
apiKey = "";
}
}
/** Returns true if an API key is configured. */
public boolean isEnabled() {
return !apiKey.isBlank();
}
}
}
```
#### ~~5. Create `FetePropertiesConfig` configuration class~~ (deferred)
**File**: `backend/src/main/java/de/fete/config/FetePropertiesConfig.java` (new)
**Changes**: Separate `@Configuration` that enables `FeteProperties`.
```java
package de.fete.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/** Activates {@link FeteProperties} binding. */
@Configuration
@EnableConfigurationProperties(FeteProperties.class)
public class FetePropertiesConfig {
}
```
#### [x] 6. Set production profile in Dockerfile
**File**: `Dockerfile`
**Changes**: Add `ENV SPRING_PROFILES_ACTIVE=prod` in the runtime stage, before `ENTRYPOINT`.
```dockerfile
# Stage 3: Runtime
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
COPY --from=backend-build /app/backend/target/*.jar app.jar
EXPOSE 8080
ENV SPRING_PROFILES_ACTIVE=prod
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
```
### Success Criteria:
#### Automated Verification:
- [ ] `cd backend && ./mvnw compile` succeeds (FeteProperties compiles, checkstyle passes)
- [ ] `docker build .` succeeds
#### Manual Verification:
- [ ] `application-prod.properties` contains all five env-var placeholders
- [ ] `application-local.properties.example` is committed; `application-local.properties` is gitignored
- [ ] `FeteProperties` fields: `unsplash.apiKey` (String), `maxActiveEvents` (int)
- [ ] Dockerfile has `ENV SPRING_PROFILES_ACTIVE=prod` before `ENTRYPOINT`
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Phase 3: Testcontainers Integration
### Overview
Set up the TestApplication pattern so that all `@SpringBootTest` tests automatically get a Testcontainers-managed PostgreSQL instance. This is critical: once JPA is on the classpath, every `@SpringBootTest` needs a datasource. Without this, all three existing `@SpringBootTest` tests break.
### Changes Required:
#### [x] 1. Create Testcontainers configuration
**File**: `backend/src/test/java/de/fete/TestcontainersConfig.java` (new)
**Changes**: Registers a PostgreSQL Testcontainer with `@ServiceConnection` for automatic datasource wiring.
```java
package de.fete;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
/** Provides a Testcontainers PostgreSQL instance for integration tests. */
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:17-alpine");
}
}
```
#### [x] 2. Create TestFeteApplication for `spring-boot:test-run`
**File**: `backend/src/test/java/de/fete/TestFeteApplication.java` (new)
**Changes**: Entry point that imports `TestcontainersConfig`. Enables `./mvnw spring-boot:test-run` for local development with Testcontainers (no external PostgreSQL needed).
```java
package de.fete;
import org.springframework.boot.SpringApplication;
/** Test entry point — starts the app with Testcontainers PostgreSQL. */
public class TestFeteApplication {
public static void main(String[] args) {
SpringApplication.from(FeteApplication::main)
.with(TestcontainersConfig.class)
.run(args);
}
}
```
#### [x] 3. Import TestcontainersConfig in existing `@SpringBootTest` tests
**File**: `backend/src/test/java/de/fete/FeteApplicationTest.java`
**Changes**: Add `@Import(TestcontainersConfig.class)` so the test gets a datasource.
```java
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
class FeteApplicationTest {
// ... existing tests unchanged
}
```
**File**: `backend/src/test/java/de/fete/config/WebConfigTest.java`
**Changes**: Same — add `@Import(TestcontainersConfig.class)`.
Note: `HexagonalArchitectureTest` uses `@AnalyzeClasses` (ArchUnit), not `@SpringBootTest` — it needs no changes.
#### [x] 4. Add SpotBugs exclusion for Testcontainers resource management
**File**: `backend/spotbugs-exclude.xml`
**Changes**: Testcontainers `PostgreSQLContainer` bean intentionally has container lifecycle managed by Spring, not try-with-resources. SpotBugs may flag this. Add exclusion if needed — check after running `./mvnw verify`.
### Success Criteria:
#### Automated Verification:
- [ ] `cd backend && ./mvnw test` — all existing tests pass (context loads, health endpoint, ArchUnit)
- [ ] `cd backend && ./mvnw verify` — full verify including SpotBugs passes
- [ ] Testcontainers starts a PostgreSQL container during test execution (visible in test output)
- [ ] Liquibase baseline migration runs against Testcontainers PostgreSQL
#### Manual Verification:
- [ ] `./mvnw spring-boot:test-run` starts the app with a Testcontainers PostgreSQL (for local dev without external DB)
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
---
## Phase 4: README Deployment Documentation
### Overview
Add a deployment section to the README with a docker-compose example, environment variable documentation, and local development setup instructions.
### Changes Required:
#### [x] 1. Add deployment section to README
**File**: `README.md`
**Changes**: Add a `## Deployment` section after the existing `## Code quality` section and before `## License`. Contains the docker-compose example, environment variable table, and notes.
```markdown
## Deployment
### Docker Compose
The app ships as a single Docker image. It requires an external PostgreSQL database.
```yaml
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: fete
POSTGRES_USER: fete
POSTGRES_PASSWORD: changeme
volumes:
- fete-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fete"]
interval: 5s
timeout: 3s
retries: 5
app:
image: gitea.example.com/user/fete:latest
ports:
- "8080:8080"
environment:
DATABASE_URL: jdbc:postgresql://db:5432/fete
DATABASE_USERNAME: fete
DATABASE_PASSWORD: changeme
# MAX_ACTIVE_EVENTS: 100
# UNSPLASH_API_KEY: your-key-here
depends_on:
db:
condition: service_healthy
volumes:
fete-db:
```
### Environment variables
| Variable | Required | Default | Description |
|----------------------|----------|-----------|------------------------------------------------|
| `DATABASE_URL` | Yes | — | JDBC connection string for PostgreSQL |
| `DATABASE_USERNAME` | Yes | — | Database username |
| `DATABASE_PASSWORD` | Yes | — | Database password |
| `MAX_ACTIVE_EVENTS` | No | Unlimited | Maximum number of simultaneously active events |
| `UNSPLASH_API_KEY` | No | — | Unsplash API key for header image search |
```
#### [x] 2. Add local development setup section to README
**File**: `README.md`
**Changes**: Extend the `## Development` section with database setup instructions.
```markdown
### Local database setup
**Option A: Testcontainers (no external PostgreSQL needed)**
```bash
cd backend && ./mvnw spring-boot:test-run
```
This starts the app with a Testcontainers-managed PostgreSQL that is created and destroyed automatically.
**Option B: External PostgreSQL**
```bash
cd backend
cp src/main/resources/application-local.properties.example \
src/main/resources/application-local.properties
# Edit application-local.properties if your PostgreSQL uses different credentials
./mvnw spring-boot:run -Dspring-boot.run.profiles=local
```
```
### Success Criteria:
#### Automated Verification:
- [ ] `cd frontend && npm run test:unit -- --run` — frontend tests still pass (no regression)
#### Manual Verification:
- [ ] README docker-compose example is syntactically correct YAML
- [ ] Environment variable table lists all five variables with correct Required/Default values
- [ ] Local development section documents both Testcontainers and external PostgreSQL options
- [ ] docker-compose startup: `docker compose up` starts app + postgres, `/actuator/health` returns `{"status":"UP"}`
**Implementation Note**: After completing this phase, all T-4 acceptance criteria should be met. Run the full verification checklist below.
---
## Testing Strategy
### Unit Tests:
- `FeteProperties` — verify defaults (empty API key = disabled, maxActiveEvents=0 = unlimited)
- No other new unit tests in T-4 — the infrastructure is verified by integration tests
### Integration Tests:
- Existing `FeteApplicationTest.contextLoads()` — validates that Spring context starts with JPA + Liquibase + Testcontainers
- Existing `FeteApplicationTest.healthEndpointReturns200()` — validates health check includes DB health
- Existing `WebConfigTest` — validates SPA routing still works with JPA on classpath
- ArchUnit rules — validate `FeteProperties`/`FetePropertiesConfig` in `config` adapter is properly isolated
### Manual Testing Steps:
1. `cd backend && ./mvnw verify` — full backend pipeline green
2. `cd frontend && npm run test:unit -- --run` — frontend unchanged
3. `docker build .` — image builds successfully
4. docker-compose (app + postgres) — start, wait for health, verify `/actuator/health` returns UP
## Performance Considerations
- Testcontainers PostgreSQL startup adds ~3-5 seconds to backend test execution. This is acceptable for integration tests.
- Testcontainers reuses the container across all `@SpringBootTest` classes in a single Maven run (Spring's test context caching).
- The empty baseline changeset adds negligible startup time.
## Migration Notes
- **Existing tests**: `FeteApplicationTest` and `WebConfigTest` need `@Import(TestcontainersConfig.class)` — without it, they fail because JPA requires a datasource.
- **CI pipeline**: `./mvnw -B verify` now requires Docker for Testcontainers. Gitea Actions `ubuntu-latest` runners have Docker available. If the runner uses Docker-in-Docker, `DOCKER_HOST` may need configuration — verify after implementation.
- **Local development**: Developers now need either Docker (for Testcontainers via `./mvnw spring-boot:test-run`) or a local PostgreSQL (with `application-local.properties`).
## References
- Research: `docs/agents/research/2026-03-04-t4-development-infrastructure.md`
- T-4 spec: `spec/setup-tasks.md` (lines 79-98)
- Spring Boot Testcontainers: `@ServiceConnection` pattern (Spring Boot 3.1+)
- Liquibase Spring Boot integration: auto-configured when `liquibase-core` is on classpath

View File

@@ -0,0 +1,359 @@
---
date: 2026-03-04T19:37:59.203261+00:00
git_commit: cb0bcad145b03fec63be0ee3c1fca46ee545329e
branch: master
topic: "T-4: Development Infrastructure Setup"
tags: [research, codebase, t4, database, liquibase, testcontainers, router, test-infrastructure, docker-compose]
status: complete
---
# Research: T-4 Development Infrastructure Setup
## Research Question
What is the current state of the codebase relative to T-4's acceptance criteria? What already exists, what is missing, and what are the technical considerations for each criterion?
## Summary
T-4 is the final infrastructure task before user story implementation can begin. It bridges the gap between the existing project scaffolds (T-1, T-2, T-3, T-5 — all complete) and actual feature development with TDD. The task covers six areas: database migration framework, database connectivity, environment variable configuration, SPA router, backend test infrastructure, frontend test infrastructure, docker-compose documentation, and container verification with PostgreSQL.
The codebase already has partial coverage: Vue Router exists with placeholder routes, frontend test infrastructure (Vitest + @vue/test-utils) is operational, and backend test infrastructure (JUnit 5 + Spring Boot Test + MockMvc) is partially in place. What's missing: JPA/Liquibase, Testcontainers, environment variable wiring, and docker-compose documentation.
## Detailed Findings
### AC 1: Database Migration Framework (Liquibase)
**Current state:** Not present. No Liquibase or Liquibase dependency in `pom.xml`. No migration files anywhere in the project. The CLAUDE.md explicitly states "No JPA until T-4."
**What's needed:**
- Add `spring-boot-starter-data-jpa` dependency to `backend/pom.xml`
- Add `liquibase-core` dependency (Spring Boot manages the version)
- Create changelog directory at `backend/src/main/resources/db/changelog/`
- Create master changelog: `db.changelog-master.xml` that includes individual changesets
- Create first empty/baseline changeset to prove the tooling works
- Spring Boot auto-configures Liquibase when it's on the classpath and a datasource is available — no explicit `@Bean` config needed
**Spring Boot + Liquibase conventions:**
- Default changelog location: `classpath:db/changelog/db.changelog-master.xml`
- Format: XML (chosen for schema validation and explicitness)
- Changelogs are DB-agnostic — Liquibase generates dialect-specific SQL at runtime
- Spring Boot 3.5.x ships Liquibase via its dependency management
- Liquibase runs automatically on startup before JPA entity validation
**Architectural note:** The hexagonal architecture has an existing `adapter.out.persistence` package (currently empty, with `package-info.java`). JPA repositories and entity classes will go here. Domain model classes remain in `domain.model` without JPA annotations — the persistence adapter maps between them. The existing ArchUnit tests already enforce this boundary.
### AC 2: Database Connectivity via Environment Variables
**Current state:** `application.properties` has no datasource configuration. Only `spring.application.name=fete` and actuator settings.
**What's needed:**
- Configure Spring datasource properties to read from environment variables via profile-based separation
**Chosen approach: Profile-based separation with generic env vars.**
The properties are split across three files with clear responsibilities:
**`application.properties`** — environment-independent, always active:
```properties
spring.application.name=fete
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never
```
**`application-prod.properties`** — committed, production profile, activated in Docker via `ENV SPRING_PROFILES_ACTIVE=prod`:
```properties
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
```
**`application-local.properties`** — gitignored, developer creates from `.example` template:
```properties
spring.datasource.url=jdbc:postgresql://localhost:5432/fete
spring.datasource.username=fete
spring.datasource.password=fete
```
**`application-local.properties.example`** — committed as template, never directly used.
**Dockerfile:**
```dockerfile
ENV SPRING_PROFILES_ACTIVE=prod
```
Key points:
- No datasource defaults in `application.properties` — if neither profile nor env vars are set, the app fails to start (intentional: no silent fallback to a nonexistent DB)
- Generic env var names (`DATABASE_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`) — the container user never sees Spring property names
- `ddl-auto=validate` ensures Hibernate validates entities against the Liquibase-managed schema but never modifies it
- `open-in-view=false` prevents the anti-pattern of lazy-loading in views (also avoids Spring Boot's startup warning)
- PostgreSQL JDBC driver (`org.postgresql:postgresql`) is needed — Spring Boot manages the version
- Tests use `@ServiceConnection` (Testcontainers) which auto-configures the datasource — no profile or env vars needed for tests
### AC 3: All Runtime Configuration via Environment Variables
**Current state:** No environment-variable-driven configuration exists beyond Spring Boot defaults.
**What's needed beyond database:**
- Unsplash API key: optional, used by US-16
- Max active events: optional, used by US-13
**Implementation pattern:** `@ConfigurationProperties(prefix = "fete")` class (`FeteProperties`) in `de.fete.config`. Type-safe, validatable, testable.
These properties also go in `application-prod.properties` with generic env var mapping:
```properties
fete.unsplash.api-key=${UNSPLASH_API_KEY:}
fete.max-active-events=${MAX_ACTIVE_EVENTS:0}
```
Empty `UNSPLASH_API_KEY` = feature disabled. `MAX_ACTIVE_EVENTS=0` = unlimited.
**Note:** These properties are only scaffolded in T-4 (the `FeteProperties` class with fields and defaults). The business logic using them comes with US-13/US-16.
### AC 4: SPA Router Configuration
**Current state:** Vue Router IS configured and operational.
**File:** `frontend/src/router/index.ts`
- Uses `createWebHistory` (HTML5 History API — clean URLs, no hash)
- Two routes defined: `/` (HomeView, eager) and `/about` (AboutView, lazy-loaded)
- Router is registered in `main.ts` via `app.use(router)`
**Backend SPA support:** Already implemented in `WebConfig.java`:
- `PathResourceResolver` falls back to `index.html` for any path not matching a static file
- This enables client-side routing — the backend serves `index.html` for all non-API, non-static paths
**Assessment:** This AC is effectively already met. The router exists, uses history mode, and the backend supports it. What will change during user stories: routes will be added (e.g., `/event/:token`, `/event/:token/edit`), but the infrastructure is in place.
### AC 5: Backend Test Infrastructure
**Current state:** Partially in place.
**What exists:**
- JUnit 5 (via `spring-boot-starter-test`) — operational
- Spring Boot Test with `@SpringBootTest` — operational
- MockMvc for REST endpoint testing — operational (`FeteApplicationTest.java`, `WebConfigTest.java`)
- ArchUnit for architecture validation — operational (`HexagonalArchitectureTest.java`)
- Surefire configured with fail-fast (`skipAfterFailureCount=1`)
- Test logging configured (`logback-test.xml` at WARN level)
**What's missing:**
- **Testcontainers** — not in `pom.xml`, no test configuration for it
- **Integration test support with real PostgreSQL** — currently no database tests exist (because no database exists yet)
**What's needed:**
- Add `org.testcontainers:postgresql` dependency (test scope)
- Add `org.testcontainers:junit-jupiter` dependency (test scope) — JUnit 5 integration
- Add `spring-boot-testcontainers` dependency (test scope) — Spring Boot 3.1+ Testcontainers integration
- Create a test configuration class or use `@ServiceConnection` annotation (Spring Boot 3.1+) for automatic datasource wiring in tests
**Spring Boot 3.5 + Testcontainers pattern (TestApplication):**
A `TestFeteApplication.java` in `src/test/` registers Testcontainers beans. All `@SpringBootTest` tests automatically get a PostgreSQL instance — no per-test wiring needed. Existing tests (`FeteApplicationTest`, `WebConfigTest`) continue to work without modification.
```java
// src/test/java/de/fete/TestFeteApplication.java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:17-alpine");
}
}
```
With `@ServiceConnection`, Spring Boot auto-configures the datasource to point at the Testcontainers-managed PostgreSQL — no manual URL/username/password wiring needed. Testcontainers starts one shared container per test suite run, not per test class.
**Important:** Once JPA is on the classpath, every `@SpringBootTest` needs a datasource. The TestApplication pattern ensures this globally. Without it, all existing `@SpringBootTest` tests would break immediately.
**Test categories after T-4:**
- Unit tests: no Spring context, no database — fast, test domain logic
- Integration tests: `@SpringBootTest` + Testcontainers — test full stack including database
- Architecture tests: ArchUnit — already in place
### AC 6: Frontend Test Infrastructure
**Current state:** Already in place and operational.
**What exists:**
- Vitest configured (`vitest.config.ts`): jsdom environment, bail=1, e2e excluded
- `@vue/test-utils` v2.4.6 installed — Vue component mounting and assertions
- TypeScript test config (`tsconfig.vitest.json`) with jsdom types
- Sample test exists: `components/__tests__/HelloWorld.spec.ts` — mounts component, asserts text
- Test command: `npm run test:unit` (runs Vitest in watch mode) / `npm run test:unit -- --run` (single run)
**Assessment:** This AC is already met. The test infrastructure is functional with a passing sample test.
### AC 7: Both Test Suites Executable
**Current state:** Both work.
- Backend: `cd backend && ./mvnw test` — runs JUnit 5 tests (3 tests in 3 classes)
- Frontend: `cd frontend && npm run test:unit -- --run` — runs Vitest (1 test in 1 file)
- CI pipeline (`ci.yaml`) already runs both in parallel
**Assessment:** Already met. Will remain met after adding Testcontainers (new tests use the same `./mvnw test` command).
### AC 8: README Docker-Compose Documentation
**Current state:** No docker-compose file or documentation exists. The README covers development setup and code quality but has no deployment section.
**What's needed:**
- A `docker-compose.yml` example (either in-repo or documented in README)
- Must include: app service (the fete container) + postgres service
- Must document required environment variables: `DATABASE_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`
- Must document optional environment variables: `UNSPLASH_API_KEY`, `MAX_ACTIVE_EVENTS`
- Per CLAUDE.md: "A docker-compose example in the README is sufficient" — no separate file in repo
**Example structure:**
```yaml
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: fete
POSTGRES_USER: fete
POSTGRES_PASSWORD: changeme
volumes:
- fete-db:/var/lib/postgresql/data
app:
image: gitea.example.com/user/fete:latest
ports:
- "8080:8080"
environment:
DATABASE_URL: jdbc:postgresql://db:5432/fete
DATABASE_USERNAME: fete
DATABASE_PASSWORD: changeme
# MAX_ACTIVE_EVENTS: 100 # optional
# UNSPLASH_API_KEY: abc123 # optional
depends_on:
db:
condition: service_healthy
volumes:
fete-db:
```
### AC 9: Container Health Check with PostgreSQL
**Current state:** The Dockerfile has a HEALTHCHECK directive that queries `/actuator/health`. Currently the app starts without a database and the health check passes.
**After T-4:** With JPA and Liquibase on the classpath, Spring Boot will:
- Fail to start if no database is reachable (datasource auto-configuration fails)
- Include database health in `/actuator/health` automatically (via `DataSourceHealthIndicator`)
- Run Liquibase migrations on startup — if migrations fail, the app won't start
**What's needed for verification:**
- Start the app with docker-compose (app + postgres)
- Verify `/actuator/health` returns `{"status":"UP"}` (which now includes DB connectivity)
- Verify Liquibase ran the baseline migration (check `flyway_schema_history` table or app logs)
## Code References
### Existing Files (will be modified)
- `backend/pom.xml:1-170` — Add JPA, Liquibase, PostgreSQL driver, Testcontainers dependencies
- `backend/src/main/resources/application.properties:1-4` — Add datasource, JPA, Liquibase, app-specific config
- `README.md:1-134` — Add deployment section with docker-compose example
### Existing Files (relevant context, likely untouched)
- `backend/src/main/java/de/fete/config/WebConfig.java:1-40` — SPA routing already configured
- `backend/src/main/java/de/fete/FeteApplication.java` — Entry point, no changes needed
- `frontend/src/router/index.ts:1-23` — Router already configured
- `frontend/vitest.config.ts:1-15` — Test infra already configured
- `frontend/package.json:1-52` — Test dependencies already present
- `.gitea/workflows/ci.yaml` — CI pipeline, may need Testcontainers Docker access for backend tests
### New Files (to be created)
- `backend/src/main/resources/db/changelog/db.changelog-master.xml` — Liquibase master changelog
- `backend/src/main/resources/application-prod.properties` — Production profile with env var placeholders
- `backend/src/main/resources/application-local.properties.example` — Template for local development
- `backend/src/test/java/de/fete/TestFeteApplication.java` (or similar) — Testcontainers PostgreSQL bean via TestApplication pattern
- `de/fete/config/FeteProperties.java``@ConfigurationProperties` class for app-specific settings
- README deployment section — docker-compose example inline (no standalone file)
- `backend/src/main/resources/application-prod.properties` — Production profile with env var placeholders
- `backend/src/main/resources/application-local.properties.example` — Template for local development
### Package Structure (existing, will gain content)
- `de.fete.adapter.out.persistence` — JPA entities and Spring Data repositories (empty now)
- `de.fete.domain.model` — Domain entities (empty now, no JPA annotations here)
- `de.fete.config` — App configuration (WebConfig exists, may add `@ConfigurationProperties` class)
## Architecture Documentation
### Hexagonal Architecture and JPA
The existing ArchUnit tests (`HexagonalArchitectureTest.java`) enforce:
- Domain layer must not depend on Spring, adapters, application, or config
- Ports (in/out) must be interfaces
- Web adapter and persistence adapter must not cross-depend
This means JPA integration must follow the pattern:
1. Domain entities in `domain.model` — plain Java, no JPA annotations
2. JPA entities in `adapter.out.persistence` — annotated with `@Entity`, `@Table`, etc.
3. Mapping between domain and JPA entities in the persistence adapter
4. Repository interfaces (Spring Data) in `adapter.out.persistence`
5. Port interfaces in `domain.port.out` — define what the domain needs from persistence
6. Service implementations in `application.service` — use port interfaces, not repositories directly
This is a well-established hexagonal pattern. The ArchUnit tests will automatically validate any new code follows these boundaries.
### Test Architecture After T-4
```
Test Type | Context | Database | Speed | Purpose
-------------------|---------------|-----------------|---------|---------------------------
Unit tests | None | None | Fast | Domain logic, services
Integration tests | SpringBoot | Testcontainers | Medium | Full stack, DB queries
Architecture tests | None (static) | None | Fast | Structural validation
```
All test types run via `./mvnw test`. Testcontainers starts/stops PostgreSQL containers automatically — no external setup needed. The CI pipeline already has Docker available (runs on `ubuntu-latest` with Docker socket).
### CI Pipeline Considerations
The current CI pipeline runs `./mvnw -B verify` for backend tests. Testcontainers requires Docker socket access. On Gitea Actions with `ubuntu-latest` runners, Docker is typically available. If the runner uses a Docker-in-Docker setup, the Testcontainers `DOCKER_HOST` environment variable may need configuration — but this is a runtime concern, not a code concern.
### Spring Boot Profiles
Currently no profiles are configured. For T-4, a `test` profile may be useful to separate test-specific configuration (e.g., Testcontainers datasource) from production defaults. Spring Boot's `@ActiveProfiles("test")` on test classes or `application-test.properties` can handle this. However, with `@ServiceConnection`, Testcontainers auto-configures the datasource without profile-specific properties.
## Acceptance Criteria Status Matrix
| # | Criterion | Current Status | Work Required |
|---|-----------|----------------|---------------|
| 1 | Liquibase configured with first changelog | Not started | Add `liquibase-core`, create changelog dir and master XML |
| 2 | External PostgreSQL via env var | Not started | Add datasource properties with env var placeholders |
| 3 | All runtime config via env vars | Not started | Add datasource + app-specific properties |
| 4 | SPA router configured | **Done** | Vue Router with history mode already works |
| 5 | Backend test infra (Testcontainers) | Partial | JUnit 5 + MockMvc exist; add Testcontainers |
| 6 | Frontend test infra | **Done** | Vitest + @vue/test-utils operational |
| 7 | Both test suites executable | **Done** | Both `./mvnw test` and `npm run test:unit` work |
| 8 | README docker-compose documentation | Not started | Add deployment section with example |
| 9 | Container health with PostgreSQL | Not started | Verify after JPA/Liquibase are added |
## Resolved Decisions
1. **Liquibase** for database migrations, **XML** format. DB-agnostic changelogs — Liquibase generates dialect-specific SQL at runtime. XML chosen over YAML for schema validation and explicitness. The project intentionally avoids PostgreSQL-specific features in migrations to keep the database layer portable.
2. **Profile-based properties separation** with generic environment variable names. Three files: `application.properties` (environment-independent, always active), `application-prod.properties` (committed, maps `${DATABASE_URL}` etc. to Spring properties, activated in Docker via `ENV SPRING_PROFILES_ACTIVE=prod`), `application-local.properties` (gitignored, concrete local values, activated via `-Dspring-boot.run.profiles=local`). A committed `.example` template guides developers. The container user sets `DATABASE_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` — never sees Spring internals.
3. **`@ConfigurationProperties`** for app-specific settings (`FeteProperties` class). Type-safe, validatable, testable. Properties: `fete.unsplash.api-key` (from `UNSPLASH_API_KEY`) and `fete.max-active-events` (from `MAX_ACTIVE_EVENTS`). Both are only scaffolded in T-4; business logic using them comes with US-13/US-16.
4. **docker-compose example in README only** — no standalone `docker-compose.yml` in the repo. Per CLAUDE.md: "A docker-compose example in the README is sufficient." A local docker-compose for development may be added later separately.
5. **TestApplication pattern** for Testcontainers integration. A `TestFeteApplication.java` in `src/test/` registers a `@ServiceConnection` PostgreSQL container. All `@SpringBootTest` tests automatically get a database — existing tests continue to work without modification.
6. **README erweitern** with local development setup documentation (how to copy `application-local.properties.example`, start with profile, PostgreSQL prerequisites).
## Open Questions
1. **Testcontainers in CI:** The Gitea Actions runner needs Docker available for Testcontainers. This works out-of-the-box on `ubuntu-latest` but should be verified after implementation.
2. **Baseline changelog content:** The first Liquibase changeset should be a minimal, empty changeset that proves the tooling works. No schema needed yet — US-1 will create the first real table.

View File

@@ -0,0 +1,121 @@
# Feature Specification: Development Infrastructure Setup
**Feature**: `004-dev-infrastructure`
**Created**: 2026-03-06
**Status**: Implemented
**Source**: Migrated from spec/setup-tasks.md
> **Note**: This is a setup task (infrastructure), not a user-facing feature. It establishes the development foundation required before the first user story can be implemented with TDD.
## User Scenarios & Testing
### Setup Task 1 - Database Connectivity and Migration (Priority: P1)
The application connects to an external PostgreSQL database via environment variables. A database migration framework (Flyway or Liquibase) is configured and runs migrations on startup against the PostgreSQL instance.
**Why this priority**: Without a running database, no user story can be implemented or tested.
**Independent Test**: Can be tested by starting the application with a PostgreSQL instance and verifying that migrations run and the health check responds.
**Acceptance Scenarios**:
1. **Given** a PostgreSQL instance is available, **When** the application starts with `DATABASE_URL` (or `SPRING_DATASOURCE_*`) environment variables set, **Then** it connects successfully and runs migrations.
2. **Given** the migration framework is configured, **When** `mvnw compile` and `mvnw spring-boot:run` execute, **Then** a first empty migration completes without errors.
3. **Given** the Docker container is started with a running PostgreSQL, **When** the health-check endpoint is queried, **Then** it responds successfully (migrations ran on startup).
---
### Setup Task 2 - Runtime Configuration via Environment Variables (Priority: P1)
All runtime configuration is exposed as environment variables: database connection, optional Unsplash API key, and optional max active events limit. No credentials or settings are hard-coded.
**Why this priority**: Required for secure, portable deployment (Docker/compose).
**Independent Test**: Can be tested by verifying that the application reads all documented environment variables and that the docker-compose example works with only environment variables.
**Acceptance Scenarios**:
1. **Given** all runtime config is environment-driven, **When** the app starts with only environment variables set, **Then** database connection, optional Unsplash key, and optional max-events limit are all honoured.
2. **Given** a docker-compose file exists in the README, **When** it is run as documented, **Then** the app container and PostgreSQL container start and the application is reachable.
---
### Setup Task 3 - Backend Test Infrastructure (Priority: P1)
The backend test infrastructure is set up with JUnit 5, Spring Boot Test, and Testcontainers (PostgreSQL) so that integration tests can run against a real database without external setup.
**Why this priority**: TDD (mandated by CLAUDE.md) requires test infrastructure before any feature implementation begins.
**Independent Test**: Can be tested by running `mvnw test` and confirming that the sample tests pass, including integration tests that spin up a PostgreSQL container.
**Acceptance Scenarios**:
1. **Given** the backend test infrastructure is configured, **When** `./mvnw test` is run, **Then** JUnit 5 tests execute and the sample test passes.
2. **Given** Testcontainers (PostgreSQL) is configured, **When** an integration test annotated with `@SpringBootTest` runs, **Then** a real PostgreSQL container is spun up automatically and the test runs against it.
---
### Setup Task 4 - Frontend Test Infrastructure (Priority: P1)
The frontend test infrastructure is set up with Vitest and `@vue/test-utils` so that Vue component tests can be written and run.
**Why this priority**: TDD on the frontend requires Vitest to be configured before any component is implemented.
**Independent Test**: Can be tested by running `npm test` (or `npx vitest`) and verifying that a sample test passes.
**Acceptance Scenarios**:
1. **Given** Vitest and `@vue/test-utils` are configured, **When** `npm run test:unit` is run, **Then** a sample test executes and passes successfully.
---
### Setup Task 5 - SPA Router Configuration (Priority: P2)
Vue Router is configured in the frontend so that pages can be navigated by URL path (client-side routing).
**Why this priority**: Required before any multi-page user story can be implemented.
**Independent Test**: Can be tested by starting the dev server and navigating to a defined route by URL, verifying the correct view is rendered.
**Acceptance Scenarios**:
1. **Given** Vue Router is configured, **When** a user navigates to a defined URL path, **Then** the corresponding view is rendered without a full page reload.
---
### Edge Cases
- What happens when `DATABASE_URL` is not set? Application should fail fast with a clear error on startup.
- What happens when PostgreSQL is unreachable at startup? Migration should fail visibly with an actionable error message.
- What happens when a migration fails? Application must not start; the error must be logged clearly.
## Requirements
### Functional Requirements
- **FR-001**: System MUST connect to an external PostgreSQL database via environment variables (`DATABASE_URL` or `SPRING_DATASOURCE_*`).
- **FR-002**: System MUST run database migrations on startup using Flyway or Liquibase; a first empty migration MUST succeed.
- **FR-003**: All runtime configuration (database connection, optional Unsplash API key, optional max active events) MUST be configurable via environment variables.
- **FR-004**: The Vue frontend MUST have Vue Router configured so that pages are navigatable by URL path.
- **FR-005**: The backend MUST have JUnit 5 with Spring Boot Test configured; integration tests MUST use Testcontainers (PostgreSQL) for database isolation.
- **FR-006**: The frontend MUST have Vitest with `@vue/test-utils` configured; a sample test MUST run and pass.
- **FR-007**: Both test suites MUST be executable via their respective build tools (`./mvnw test` for backend, `npm run test:unit` for frontend).
- **FR-008**: The README MUST document a docker-compose example (app + PostgreSQL) for deployment.
- **FR-009**: The Docker container MUST start and respond to health checks with a running PostgreSQL instance (migrations run on startup).
### Key Entities
- **Environment Configuration**: All runtime settings injected via environment variables; no hard-coded credentials.
- **Database Migration**: Versioned migration scripts managed by Flyway or Liquibase; run automatically on startup.
## Success Criteria
### Measurable Outcomes
- **SC-001**: `./mvnw test` completes without failures; integration tests spin up a real PostgreSQL container via Testcontainers.
- **SC-002**: `npm run test:unit` completes without failures; sample component test passes.
- **SC-003**: `docker-compose up` (using the README example) starts both containers and the application responds to health checks.
- **SC-004**: All runtime configuration is driven exclusively by environment variables; no credentials or settings are hard-coded in source.
- **SC-005**: Vue Router is configured; navigating to a defined URL path renders the correct view.
**Addendum (2026-03-04):** T-4 absorbed database connectivity, environment variable configuration, and docker-compose documentation from T-2 (see T-2 addendum). These criteria require JPA and Flyway to be testable, so they belong here rather than in T-2.

View File

@@ -0,0 +1,100 @@
# Feature Specification: API-First Tooling Setup
**Feature**: `005-api-first-tooling`
**Created**: 2026-03-06
**Status**: Implemented
**Source**: Migrated from spec/setup-tasks.md (setup task — infrastructure, not user-facing)
## User Scenarios & Testing
### Setup Story 1 - Backend OpenAPI Code Generation (Priority: P1)
The development toolchain generates Java server interfaces and model classes from the OpenAPI spec so that the backend implementation always matches the API contract.
**Why this priority**: Code generation from the spec is the foundation of API-first development. Without it, backend implementation can diverge from the contract.
**Independent Test**: Run `./mvnw compile` and verify that generated sources appear in `target/generated-sources/openapi/`.
**Acceptance Scenarios**:
1. **Given** the OpenAPI spec exists at `backend/src/main/resources/openapi/api.yaml`, **When** `./mvnw compile` is run, **Then** Java interfaces and model classes are generated into `target/generated-sources/openapi/` with packages `de.fete.adapter.in.web.api` and `de.fete.adapter.in.web.model`.
2. **Given** the generator is configured with `interfaceOnly: true`, **When** compilation completes, **Then** only interfaces (not implementations) are generated, keeping implementation separate from contract.
---
### Setup Story 2 - Frontend TypeScript Type Generation (Priority: P1)
The development toolchain generates TypeScript types from the OpenAPI spec so that the frontend is always type-safe against the API contract.
**Why this priority**: Type generation ensures frontend-backend contract alignment at compile time.
**Independent Test**: Run `npm run generate:api` and verify that `frontend/src/api/schema.d.ts` is created with types matching the spec.
**Acceptance Scenarios**:
1. **Given** `openapi-typescript` is installed as a devDependency and `openapi-fetch` as a dependency, **When** `npm run generate:api` is run, **Then** TypeScript types are generated into `frontend/src/api/schema.d.ts`.
2. **Given** the `dev` and `build` scripts include type generation as a pre-step, **When** `npm run dev` or `npm run build` is run, **Then** types are regenerated automatically before the build proceeds.
---
### Setup Story 3 - Minimal API Client (Priority: P2)
A minimal API client using `openapi-fetch` is wired up so that frontend code can call the backend with full type safety.
**Why this priority**: The client is needed before any user story can make API calls, but can be a thin wrapper initially.
**Independent Test**: Verify `frontend/src/api/client.ts` exists and uses `createClient<paths>()` from `openapi-fetch`.
**Acceptance Scenarios**:
1. **Given** the generated `schema.d.ts` exists, **When** a developer imports the API client, **Then** all request/response types are fully inferred from the OpenAPI spec.
---
### Setup Story 4 - Minimal OpenAPI Spec (Priority: P1)
A minimal OpenAPI 3.1 spec exists at the canonical location and is sufficient to prove the tooling works end-to-end.
**Why this priority**: The spec is the prerequisite for all code generation. It must exist before any other story can proceed.
**Independent Test**: Run both generation steps (backend + frontend) and verify both succeed without errors.
**Acceptance Scenarios**:
1. **Given** a minimal spec at `backend/src/main/resources/openapi/api.yaml`, **When** both generation steps run, **Then** both complete successfully and the project compiles cleanly (backend + frontend).
---
### Edge Cases
- What happens when the OpenAPI spec contains a syntax error? Generation should fail with a clear error message.
- What happens when the spec is updated with a breaking change? Generated types and interfaces reflect the change, causing compile errors that force the developer to update implementations.
## Requirements
### Functional Requirements
- **FR-001**: `openapi-generator-maven-plugin` (v7.20.x, `spring` generator, `interfaceOnly: true`) MUST be configured in `backend/pom.xml`.
- **FR-002**: A minimal OpenAPI 3.1 spec MUST exist at `backend/src/main/resources/openapi/api.yaml`.
- **FR-003**: `./mvnw compile` MUST generate Java interfaces and model classes into `target/generated-sources/openapi/` with packages `de.fete.adapter.in.web.api` and `de.fete.adapter.in.web.model`.
- **FR-004**: `openapi-typescript` MUST be installed as a devDependency in the frontend.
- **FR-005**: `openapi-fetch` MUST be installed as a runtime dependency in the frontend.
- **FR-006**: `npm run generate:api` MUST generate TypeScript types from the spec into `frontend/src/api/schema.d.ts`.
- **FR-007**: Frontend `dev` and `build` scripts MUST include type generation as a pre-step.
- **FR-008**: A minimal API client at `frontend/src/api/client.ts` MUST use `createClient<paths>()` from `openapi-fetch`.
- **FR-009**: Both generation steps MUST succeed and the project MUST compile cleanly (backend + frontend).
### Key Entities
- **OpenAPI Spec**: The single source of truth for the REST API contract, located at `backend/src/main/resources/openapi/api.yaml`. A living document that grows with each user story.
- **Generated Sources**: Backend Java interfaces/models in `target/generated-sources/openapi/`; frontend TypeScript types in `frontend/src/api/schema.d.ts`.
## Success Criteria
### Measurable Outcomes
- **SC-001**: `./mvnw compile` succeeds and generated sources exist in `target/generated-sources/openapi/`.
- **SC-002**: `npm run generate:api` succeeds and `frontend/src/api/schema.d.ts` is created.
- **SC-003**: Frontend `npm run dev` and `npm run build` automatically regenerate types before building.
- **SC-004**: The project compiles cleanly end-to-end (backend + frontend) with the generated code.
- **SC-005**: A working API client exists at `frontend/src/api/client.ts` using the generated types.

View File

@@ -0,0 +1,199 @@
# US-1 Post-Review Fixes — Implementation Plan
Date: 2026-03-05
Origin: Deep review of all unstaged US-1 changes before commit
## Context
US-1 "Create Event" is fully implemented (backend + frontend, 7 phases) with 4 review fixes already applied (reactive error clearing, network error handling, page title, favicon). A comprehensive review of ALL unstaged files revealed additional issues that must be fixed before committing.
## Task 1: Backend — Clock injection in EventService [x]
**Problem:** `EventService` uses `LocalDate.now()` and `OffsetDateTime.now()` directly, making deterministic time-based testing impossible.
**Files:**
- `backend/src/main/java/de/fete/application/service/EventService.java`
- `backend/src/test/java/de/fete/application/service/EventServiceTest.java`
**Fix:**
1. Inject a `java.time.Clock` bean into `EventService` via constructor
2. Replace `LocalDate.now()` with `LocalDate.now(clock)` and `OffsetDateTime.now()` with `OffsetDateTime.now(clock)`
3. Add a `Clock` bean to the Spring config (or rely on a `@Bean Clock clock() { return Clock.systemDefaultZone(); }` in a config class)
4. Update `EventServiceTest` to use `Clock.fixed(...)` for deterministic tests
**Verification:** `cd backend && ./mvnw test`
## Task 2: Frontend A11y — Error spans should only render when error present [x]
**Problem:** Every form field has `<span class="field-error" role="alert">{{ errors.title }}</span>` that is always in the DOM, even when empty. Screen readers may announce empty `role="alert"` elements.
**File:** `frontend/src/views/EventCreateView.vue`
**Fix:** Use `v-if` to conditionally render error spans:
```html
<span v-if="errors.title" class="field-error" role="alert">{{ errors.title }}</span>
```
Apply to all 5 field error spans (title, description, dateTime, location, expiryDate).
**Note:** This removes the `min-height: 1.2em` layout reservation. Accept the layout shift as a trade-off for accessibility, OR add a wrapper div with `min-height` that doesn't carry `role="alert"`.
**Verification:** `cd frontend && npm run test:unit` — existing tests use `.querySelector('[role="alert"]')` so they may need adjustment since empty alerts will no longer be in the DOM.
## Task 3: Frontend A11y — aria-invalid and aria-describedby on fields [x]
**Problem:** When a field fails validation, there is no `aria-invalid="true"` or `aria-describedby` linking the input to its error message. Assistive technologies cannot associate errors with fields.
**File:** `frontend/src/views/EventCreateView.vue`
**Fix:**
1. Add unique `id` to each error span (e.g., `id="title-error"`)
2. Add `:aria-describedby="errors.title ? 'title-error' : undefined"` to each input
3. Add `:aria-invalid="!!errors.title"` to each input
Example for title:
```html
<input
id="title"
v-model="form.title"
type="text"
class="form-field"
required
maxlength="200"
placeholder="What's the event?"
:aria-invalid="!!errors.title"
:aria-describedby="errors.title ? 'title-error' : undefined"
/>
<span v-if="errors.title" id="title-error" class="field-error" role="alert">{{ errors.title }}</span>
```
Apply the same pattern to all 5 fields (title, description, dateTime, location, expiryDate).
**Verification:** `cd frontend && npm run test:unit`
## Task 4: Frontend A11y — Error text contrast [x]
**Problem:** White (`#fff`) error text on the pink gradient start (`#F06292`) has a contrast ratio of only 3.06:1, which fails WCAG AA for small text (0.8rem). The project statute requires WCAG AA compliance.
**File:** `frontend/src/assets/main.css`
**Fix options (pick one):**
- **Option A:** Use a light yellow/cream color like `#FFF9C4` or `#FFECB3` that has higher contrast on the gradient
- **Option B:** Add a subtle dark text-shadow to the error text: `text-shadow: 0 1px 2px rgba(0,0,0,0.3)`
- **Option C:** Make error text slightly larger/bolder to qualify for WCAG AA-large (18px+ or 14px+ bold)
**Recommended:** Option C — bump `.field-error` to `font-size: 0.85rem; font-weight: 600;` which at 600 weight qualifies for AA-large text at 14px+ (0.85rem ≈ 13.6px — close but may not quite qualify). Alternatively combine with option B for safety.
**Note:** Verify the final choice against the design system spec in `spec/design-system.md`. The spec notes that gradient start only passes AA-large. The error text must work across the full gradient.
**Verification:** Manual contrast check with a tool like WebAIM contrast checker.
## Task 5: Test — Happy-path submission in EventCreateView [x]
**Problem:** No test verifies successful form submission (the most important behavior).
**File:** `frontend/src/views/__tests__/EventCreateView.spec.ts`
**Fix:** Add a test that:
1. Mocks `api.POST` to return `{ data: { eventToken: 'abc', organizerToken: 'xyz', title: 'Test', dateTime: '...', expiryDate: '...' } }`
2. Fills all required fields
3. Submits the form
4. Asserts `api.POST` was called with the correct body
5. Asserts navigation to `/events/abc` occurred
6. Asserts `saveCreatedEvent` was called (need to mock `useEventStorage`)
**Note:** `useEventStorage` must be mocked. Use `vi.mock('@/composables/useEventStorage')`.
**Verification:** `cd frontend && npm run test:unit`
## Task 6: Test — EventStubView component tests [x]
**Problem:** No test file exists for `EventStubView.vue`.
**New file:** `frontend/src/views/__tests__/EventStubView.spec.ts`
**Fix:** Create tests covering:
1. Renders the event URL based on route param `:token`
2. Shows the correct share URL (`window.location.origin + /events/:token`)
3. Copy button exists
4. Back link navigates to home
**Note:** Read `frontend/src/views/EventStubView.vue` first to understand the component structure.
**Verification:** `cd frontend && npm run test:unit`
## Task 7: Test — Server-side field errors in EventCreateView [x]
**Problem:** The `fieldErrors` handling branch (lines 184-196 of EventCreateView.vue) is untested.
**File:** `frontend/src/views/__tests__/EventCreateView.spec.ts`
**Fix:** Add a test that:
1. Mocks `api.POST` to return `{ error: { fieldErrors: [{ field: 'title', message: 'Title already taken' }] } }`
2. Fills all required fields and submits
3. Asserts the title field error shows "Title already taken"
4. Asserts other field errors are empty
**Verification:** `cd frontend && npm run test:unit`
## Task 8: Fix border-radius on EventStubView copy button [x]
**Problem:** `border-radius: 10px` is hardcoded instead of using the design token `var(--radius-button)` (14px).
**File:** `frontend/src/views/EventStubView.vue`
**Fix:** Replace `border-radius: 10px` with `border-radius: var(--radius-button)` in the `.stub__copy` CSS class.
**Verification:** Visual check.
## Task 9: Add 404 catch-all route user story [x]
**Problem:** Navigating to an unknown path shows a blank page.
**File:** `spec/userstories.md`
**Fix:** Add a new user story for a 404/catch-all route. Something like:
```
### US-X: 404 Page
As a user who navigates to a non-existent URL, I want to see a helpful error page so I can find my way back.
Acceptance Criteria:
- [ ] Unknown routes show a "Page not found" message
- [ ] The page includes a link back to the home page
- [ ] The page follows the design system
```
Read the existing user stories first to match the format.
**Verification:** N/A (spec only).
## Task 10: EventStubView silent clipboard failure [x]
**Problem:** In `EventStubView.vue`, the `catch` block on `navigator.clipboard.writeText()` is empty. If clipboard is unavailable (HTTP, older browser), the user gets no feedback.
**File:** `frontend/src/views/EventStubView.vue`
**Fix:** In the catch block, show a fallback message (e.g., set `copied` text to "Copy failed" or select the URL text for manual copying).
**Verification:** `cd frontend && npm run test:unit`
## Execution Order
1. Task 1 (Clock injection — backend, independent)
2. Tasks 2 + 3 (A11y fixes — can be done together since they touch the same file)
3. Task 4 (Contrast fix — CSS only)
4. Tasks 5 + 7 (EventCreateView tests — same test file)
5. Task 6 (EventStubView tests — new file)
6. Tasks 8 + 10 (EventStubView fixes — same file)
7. Task 9 (User story — spec only)
8. Run all tests: `cd backend && ./mvnw test` and `cd frontend && npm run test:unit`
## Constraints
- TDD: write/update tests first, then fix (where applicable)
- Follow existing code style and patterns
- Do not refactor unrelated code
- Do not add dependencies
- Update design system spec if contrast solution changes the spec

View File

@@ -0,0 +1,109 @@
# US-1 Review Fixes — Agent Instructions
Date: 2026-03-05
Origin: Code review and exploratory browser testing of US-1 "Create Event"
## Context
US-1 has been implemented across all 7 phases (OpenAPI spec, DB migration, domain model, application service, persistence adapter, web adapter, frontend). All 42 tests pass. A code review with exploratory browser testing found 2 bugs and 2 minor issues that need to be fixed before the story can be committed.
### Resources
- **Test report:** `.agent-tests/2026-03-05-us1-review-test/report.md` — full browser test protocol with screenshots
- **Screenshots:** `.agent-tests/2026-03-05-us1-review-test/screenshots/` — visual evidence (0108)
- **US-1 spec:** `spec/userstories.md` — acceptance criteria
- **Implementation plan:** `docs/agents/plan/2026-03-04-us1-create-event.md`
- **Design system:** `spec/design-system.md`
- **Primary file to modify:** `frontend/src/views/EventCreateView.vue`
- **Secondary file to modify:** `frontend/index.html`
## Fix Instructions
### Fix 1: Validation errors must clear reactively (Bug — Medium)
**Problem:** After submitting the empty form, validation errors appear correctly. But when the user then fills in the fields, the error messages persist until the next submit. See screenshot `05-form-filled.png` — all fields filled, errors still visible.
**Root cause:** `validate()` (line 125) calls `clearErrors()` only on submit. There is no reactive clearing on input.
**Fix:** Add a `watch` on the `form` reactive object that clears the corresponding field error when the value changes. Do NOT re-validate on every keystroke — just clear the error for the field that was touched.
```typescript
// Clear individual field errors when the user types
watch(() => form.title, () => { errors.title = '' })
watch(() => form.dateTime, () => { errors.dateTime = '' })
watch(() => form.expiryDate, () => { errors.expiryDate = '' })
```
Also clear `serverError` when any field changes, so stale server errors don't linger.
**Test:** Add a test to `frontend/src/views/__tests__/EventCreateView.spec.ts` that:
1. Submits the empty form (triggers validation errors)
2. Types into the title field
3. Asserts that the title error is cleared but other errors remain
### Fix 2: Network errors must show a user-visible message (Bug — High)
**Problem:** When the backend is unreachable, the form submits silently — no error message, no feedback. The `serverError` element (line 77) exists but is never populated because `openapi-fetch` throws an unhandled exception on network errors instead of returning an `{ error }` object.
**Root cause:** `handleSubmit()` (line 150) has no `try-catch` around the `api.POST()` call (line 164). When `fetch` fails (network error), `openapi-fetch` throws, the promise rejects, and the function exits without setting `serverError` or resetting `submitting`.
**Fix:** Wrap the API call and response handling in a `try-catch`:
```typescript
try {
const { data, error } = await api.POST('/events', { body: { ... } })
submitting.value = false
if (error) {
// ... existing error handling ...
return
}
if (data) {
// ... existing success handling ...
}
} catch {
submitting.value = false
serverError.value = 'Could not reach the server. Please try again.'
}
```
**Test:** Add a test to `EventCreateView.spec.ts` that mocks the API to throw (simulating network failure) and asserts that `serverError` text appears in the DOM.
### Fix 3: Page title (Minor — Low)
**Problem:** `frontend/index.html` line 7 still has `<title>Vite App</title>`.
**Fix:** Change to `<title>fete</title>`. Also set `lang="en"` on the `<html>` tag (line 2 currently has `lang=""`).
**File:** `frontend/index.html`
### Fix 4: Favicon (Minor — Low)
**Problem:** The favicon is the Vite default. The project should either have its own favicon or remove the link entirely.
**Fix:** For now, remove the `<link rel="icon" href="/favicon.ico">` line and delete `frontend/public/favicon.ico` if it exists. A proper favicon can be added later as part of branding work.
**File:** `frontend/index.html`, `frontend/public/favicon.ico`
## Execution Order
1. Fix 3 + Fix 4 (trivial, `index.html` + favicon cleanup)
2. Fix 1 (reactive error clearing + test)
3. Fix 2 (try-catch + test)
4. Run all frontend tests: `cd frontend && npm run test:unit`
5. Verify visually with `browser-interactive-testing` skill:
- Start dev server, open `/create`
- Submit empty → errors appear
- Fill title → title error clears, others remain
- Fill all fields → all errors gone
- Submit with no backend → "Could not reach the server" message appears
## Constraints
- Follow existing code style and patterns in `EventCreateView.vue`
- Do not refactor unrelated code
- Do not add dependencies
- Tests must follow existing test patterns in `EventCreateView.spec.ts`
- TDD: write/update tests first, then fix

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
---
date: 2026-03-04T21:04:31+00:00
git_commit: 747ed189456d2328147051bb8e7b3bbb43f47ea6
branch: master
topic: "US-1: Create an Event — Codebase Research"
tags: [research, codebase, us-1, event-creation, hexagonal-architecture]
status: complete
---
# Research: US-1 — Create an Event
## Research Question
What is the current state of the codebase relevant to implementing US-1 (Create an event)? What exists, what infrastructure is in place, and what needs to be built?
## Summary
US-1 is the first user story to be implemented. All setup tasks (T-1 through T-5) are complete. The codebase provides a hexagonal architecture skeleton with ArchUnit enforcement, an API-first workflow (OpenAPI spec → generated interfaces + TypeScript types), Liquibase migration tooling with an empty baseline, Testcontainers for integration tests, and a Vue 3 SPA frontend with typed API client. No domain models, use cases, persistence adapters, or controllers exist yet — the entire business logic layer is empty and waiting for US-1.
## US-1 Acceptance Criteria (from spec/userstories.md:21-40)
- [ ] Organizer fills in: title (required), description (optional), date/time (required), location (optional), expiry date (required)
- [ ] Server stores event, returns event token (UUID) + organizer token (UUID) in creation response
- [ ] Organizer redirected to event page after creation
- [ ] Organizer token stored in localStorage for organizer access on this device
- [ ] Event token, title, date stored in localStorage for local overview (US-7)
- [ ] No account, login, or personal data required
- [ ] Expiry date is mandatory, cannot be left blank
- [ ] Event not discoverable except via direct link
Dependencies: T-4 (complete).
## Detailed Findings
### 1. Backend Architecture Skeleton
The hexagonal architecture is fully scaffolded but empty. All business-logic packages contain only `package-info.java` documentation files:
| Package | Location | Status |
|---------|----------|--------|
| `de.fete.domain.model` | `backend/src/main/java/de/fete/domain/model/` | Empty — domain entities go here |
| `de.fete.domain.port.in` | `backend/src/main/java/de/fete/domain/port/in/` | Empty — use case interfaces go here |
| `de.fete.domain.port.out` | `backend/src/main/java/de/fete/domain/port/out/` | Empty — repository ports go here |
| `de.fete.application.service` | `backend/src/main/java/de/fete/application/service/` | Empty — use case implementations go here |
| `de.fete.adapter.in.web` | `backend/src/main/java/de/fete/adapter/in/web/` | Empty hand-written code — generated HealthApi interface exists in target/ |
| `de.fete.adapter.out.persistence` | `backend/src/main/java/de/fete/adapter/out/persistence/` | Empty — JPA entities + Spring Data repos go here |
Architecture constraints are enforced by ArchUnit (`HexagonalArchitectureTest.java:1-63`):
- Domain layer must not depend on adapters, application, config, or Spring
- Inbound and outbound ports must be interfaces
- Web adapter and persistence adapter must not depend on each other
- Onion architecture layers validated via `onionArchitecture()` rule
### 2. OpenAPI Spec — Current State and Extension Point
The OpenAPI spec at `backend/src/main/resources/openapi/api.yaml:1-38` currently defines only the health check endpoint. US-1 requires adding:
- **New path:** `POST /events` — create event endpoint
- **New schemas:** Request body (title, description, dateTime, location, expiryDate) and response (eventToken, organizerToken)
- **Error responses:** RFC 9457 Problem Details format (see `docs/agents/research/2026-03-04-rfc9457-problem-details.md`)
- **Server base:** Already set to `/api` (line 11), matching `WebConfig.java:19`
Generated code lands in `target/generated-sources/openapi/`:
- Interfaces: `de.fete.adapter.in.web.api` — controller must implement generated interface
- Models: `de.fete.adapter.in.web.model` — request/response DTOs
Frontend types are generated via `npm run generate:api` into `frontend/src/api/schema.d.ts`.
### 3. Web Configuration
`WebConfig.java:1-41` configures two things relevant to US-1:
1. **API prefix** (line 19): All `@RestController` beans are prefixed with `/api`. So the OpenAPI path `/events` becomes `/api/events` at runtime.
2. **SPA fallback** (lines 23-39): Any non-API, non-static-asset request falls through to `index.html`. This means Vue Router handles client-side routes like `/events/:token`.
### 4. Database Infrastructure
**Liquibase** is configured in `application.properties:8`:
```
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
```
The master changelog (`db.changelog-master.xml:1-10`) includes a single empty baseline (`000-baseline.xml:1-13`). US-1 needs a new migration file (e.g. `001-create-event-table.xml`) added to the master changelog.
**JPA** is configured with `ddl-auto=validate` (`application.properties:4`), meaning Hibernate validates entity mappings against the schema but never auto-creates tables. Liquibase is the sole schema management tool.
**PostgreSQL** connection is externalized via environment variables in `application-prod.properties:1-4`:
```
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
```
### 5. Test Infrastructure
**Backend:**
- JUnit 5 + Spring Boot Test + MockMvc (see `FeteApplicationTest.java`)
- Testcontainers PostgreSQL (`TestcontainersConfig.java:1-17`) — real database for integration tests
- ArchUnit for architecture validation
- Checkstyle (Google Checks) and SpotBugs configured as build plugins
**Frontend:**
- Vitest with jsdom environment (`vitest.config.ts`)
- `@vue/test-utils` for component testing
- Single placeholder test exists (`HelloWorld.spec.ts`)
- Test pattern: `src/**/__tests__/*.spec.ts`
### 6. Frontend — Router, API Client, and localStorage
**Router** (`frontend/src/router/index.ts:1-23`): Currently has two placeholder routes (`/` and `/about`). US-1 needs:
- A route for the event creation form (e.g. `/create`)
- A route for the event page (e.g. `/events/:token`) — needed for post-creation redirect
**API client** (`frontend/src/api/client.ts:1-4`): Singleton `openapi-fetch` client typed against generated schema. Base URL `/api`. Ready for use — just needs the new endpoints in the generated types.
**localStorage:** No utilities exist yet. The `composables/` directory contains only `.gitkeep`. US-1 needs:
- A composable or utility for storing/retrieving organizer tokens per event
- Storage of event token, title, and date for the local overview (US-7)
**Components:** Only Vue/Vite scaffold defaults (HelloWorld, TheWelcome, icons). All need to be replaced with the actual event creation form.
### 7. Token Model
The spec defines three token types (`userstories.md:12-18`):
- **Event token**: Public UUID v4 in the event URL. Used by guests to access event pages.
- **Organizer token**: Secret UUID v4 stored in localStorage. Used to authenticate organizer actions.
- **Internal DB ID**: Never exposed — implementation detail only.
UUID v4 (random) is used for both tokens. KISS — no time-ordering (v7) needed for this use case. Generated server-side via `java.util.UUID.randomUUID()`.
### 8. Cross-Cutting Concerns
- **Date/time handling:** See `docs/agents/research/2026-03-04-datetime-best-practices.md` for the full stack-wide type mapping. Event dateTime → `OffsetDateTime` / `timestamptz`. Expiry date → `LocalDate` / `date`.
- **Error responses:** RFC 9457 Problem Details format. See `docs/agents/research/2026-03-04-rfc9457-problem-details.md`.
- **Honeypot fields:** Removed from scope — overengineered for this project.
## Code References
- `spec/userstories.md:21-40` — US-1 full specification
- `spec/implementation-phases.md:7` — US-1 is first in implementation order
- `backend/src/main/resources/openapi/api.yaml:1-38` — OpenAPI spec (extension point)
- `backend/src/main/java/de/fete/config/WebConfig.java:19` — API prefix `/api`
- `backend/src/main/java/de/fete/config/WebConfig.java:23-39` — SPA fallback routing
- `backend/src/main/resources/application.properties:4` — JPA ddl-auto=validate
- `backend/src/main/resources/application.properties:8` — Liquibase changelog config
- `backend/src/main/resources/db/changelog/db.changelog-master.xml:8` — Single include, extend here
- `backend/src/main/resources/db/changelog/000-baseline.xml:8-10` — Empty baseline changeset
- `backend/src/main/resources/application-prod.properties:1-4` — DB env vars
- `backend/src/test/java/de/fete/HexagonalArchitectureTest.java:1-63` — Architecture constraints
- `backend/src/test/java/de/fete/TestcontainersConfig.java:1-17` — Test DB container
- `frontend/src/router/index.ts:1-23` — Vue Router (extend with event routes)
- `frontend/src/api/client.ts:1-4` — API client (ready to use with generated types)
- `frontend/src/composables/.gitkeep` — Empty composables directory
## Architecture Documentation
### Hexagonal Layer Mapping for US-1
| Layer | Package | US-1 Artifacts |
|-------|---------|----------------|
| **Domain Model** | `de.fete.domain.model` | `Event` entity (title, description, dateTime, location, expiryDate, eventToken, organizerToken, createdAt) |
| **Inbound Port** | `de.fete.domain.port.in` | `CreateEventUseCase` interface |
| **Outbound Port** | `de.fete.domain.port.out` | `EventRepository` interface (save, findByToken) |
| **Application Service** | `de.fete.application.service` | `EventService` implementing `CreateEventUseCase` |
| **Web Adapter** | `de.fete.adapter.in.web` | Controller implementing generated `EventsApi` interface |
| **Persistence Adapter** | `de.fete.adapter.out.persistence` | JPA entity + Spring Data repository implementing `EventRepository` port |
| **Config** | `de.fete.config` | (existing WebConfig sufficient) |
### API-First Flow
```
api.yaml (edit) → mvn compile → HealthApi.java + EventsApi.java (generated)
HealthResponse.java + CreateEventRequest.java + CreateEventResponse.java (generated)
→ npm run generate:api → schema.d.ts (generated TypeScript types)
```
The hand-written controller in `adapter.in.web` implements the generated interface. The frontend uses the generated types via `openapi-fetch`.
### Database Schema Required
US-1 needs a single `events` table with columns mapping to the domain model. The migration file goes into `db/changelog/` and must be included in `db.changelog-master.xml`.
### Frontend Data Flow
```
EventCreateForm.vue → api.post('/events', body) → backend
← { eventToken, organizerToken }
→ localStorage.setItem (organizer token, event meta)
→ router.push(`/events/${eventToken}`)
```
## Resolved Questions
- **Expiry date validation at creation:** Yes — the server enforces that the expiry date is in the future at creation time, not only at edit time (US-5). Rationale: an event should never exist in an invalid state. If it's never edited, a past expiry date would be nonsensical. This extends US-1 AC7 beyond "mandatory" to "mandatory and in the future".
- **Event page after creation:** Option A — create a minimal stub route (`/events/:token`) with a placeholder view (e.g. "Event created" confirmation). The full event page is built in US-2. This keeps story boundaries clean while satisfying US-1 AC3 (redirect after creation).

View File

@@ -0,0 +1,97 @@
# Feature Specification: Create an Event
**Feature**: `006-create-event`
**Created**: 2026-03-06
**Status**: Approved
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Create Event with Required Fields (Priority: P1)
An event organizer fills in the event creation form with a title, date/time, and mandatory expiry date, submits it, and is redirected to the new event page. The server returns both an event token and an organizer token. The organizer token is stored in localStorage on the current device.
**Why this priority**: Core action of the entire application. All other stories depend on event creation existing. Without it, there is nothing to view, RSVP to, or manage.
**Independent Test**: Can be fully tested by submitting the creation form and verifying the redirect to the event page, localStorage state, and the server-side persistence.
**Acceptance Scenarios**:
1. **Given** the organizer opens the event creation form, **When** they fill in title, date/time, and expiry date and submit, **Then** the server stores the event and returns a UUID event token and a separate UUID organizer token in the response.
2. **Given** the event is created successfully, **When** the organizer is redirected, **Then** they land on the event page identified by the event token.
3. **Given** the event is created successfully, **When** the organizer token is received, **Then** it is stored in localStorage to grant organizer access on this device.
4. **Given** the event is created successfully, **When** the response is processed, **Then** the event token, title, and date are also stored in localStorage so the local event overview (US-7) can display the event without server contact.
5. **Given** the event creation form, **When** it is opened, **Then** no account, login, or personal data is required.
---
### User Story 2 - Optional Fields (Priority: P2)
An organizer can optionally provide a description and location when creating an event. These fields are not required but are stored alongside the event when provided.
**Why this priority**: Enriches the event page but does not block the core creation flow.
**Independent Test**: Can be tested by creating an event with and without description/location, verifying both cases result in a valid event.
**Acceptance Scenarios**:
1. **Given** the creation form, **When** the organizer leaves description and location blank and submits, **Then** the event is created successfully without those fields.
2. **Given** the creation form, **When** the organizer fills in description and location and submits, **Then** the event is created and those fields are stored.
---
### User Story 3 - Expiry Date Validation (Priority: P2)
The expiry date field is mandatory and must be set to a date in the future. The organizer cannot submit the form without providing it, and cannot set a past date.
**Why this priority**: Mandatory expiry is a core privacy guarantee (linked to US-12 data deletion). The field must always be valid at creation time.
**Independent Test**: Can be tested by attempting to submit the form without an expiry date, or with a past expiry date, and verifying the form is rejected with a clear validation message.
**Acceptance Scenarios**:
1. **Given** the creation form, **When** the organizer attempts to submit without an expiry date, **Then** the submission is rejected and the expiry date field is flagged as required.
2. **Given** the creation form, **When** the organizer enters a past date as the expiry date and submits, **Then** the submission is rejected with a clear validation message.
3. **Given** the creation form, **When** the organizer enters a future date as the expiry date and submits, **Then** the event is created successfully.
---
### Edge Cases
- What happens when the organizer submits the form with only whitespace in the title?
- How does the system handle the expiry date set to exactly today (midnight boundary)?
- What if localStorage is unavailable or full when storing the organizer token?
- What happens if the server returns an error during event creation (network failure, server error)?
## Requirements
### Functional Requirements
- **FR-001**: System MUST accept event creation with: title (required), description (optional), date and time (required), location (optional), expiry date (required).
- **FR-002**: System MUST reject event creation if title is missing.
- **FR-003**: System MUST reject event creation if date/time is missing.
- **FR-004**: System MUST reject event creation if expiry date is missing or is not in the future.
- **FR-005**: System MUST generate a unique, non-guessable UUID event token upon successful event creation.
- **FR-006**: System MUST generate a separate unique, non-guessable UUID organizer token upon successful event creation.
- **FR-007**: System MUST return both tokens in the creation response.
- **FR-008**: Frontend MUST store the organizer token in localStorage to grant organizer access on the current device.
- **FR-009**: Frontend MUST store the event token, title, and date in localStorage alongside the organizer token.
- **FR-010**: Frontend MUST redirect the organizer to the event page after successful creation.
- **FR-011**: System MUST NOT require any account, login, or personal data to create an event.
- **FR-012**: The event MUST NOT be discoverable except via its direct link (no public listing).
### Key Entities
- **Event**: Represents a scheduled gathering. Key attributes: event token (UUID, public), organizer token (UUID, secret), title, description, date/time, location, expiry date, creation timestamp.
- **Organizer Token**: A secret UUID stored in localStorage on the device where the event was created. Used to authenticate organizer actions on that device.
- **Event Token**: A public UUID embedded in the event URL. Used by guests to access the event page.
## Success Criteria
### Measurable Outcomes
- **SC-001**: An organizer can complete the event creation form and be redirected to the new event page in a single form submission.
- **SC-002**: After creation, the organizer token and event metadata are present in localStorage on the current device.
- **SC-003**: An event created without description or location renders correctly on the event page without errors.
- **SC-004**: Submitting the form with a missing or past expiry date displays a clear, user-readable validation error.
- **SC-005**: The event is not accessible via any URL other than the one containing the event token.

View File

@@ -0,0 +1,104 @@
# Feature Specification: View Event Landing Page
**Feature**: `007-view-event`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - View event details as guest (Priority: P1)
A guest receives a shared event link, opens it, and sees all relevant event information: title, description (if provided), date and time, location (if provided), and the list of confirmed attendees with a count.
**Why this priority**: Core value of the feature — without this, no other part of the event page is meaningful.
**Independent Test**: Can be fully tested by navigating to a valid event URL and verifying all event fields are displayed correctly, including attendee list and count.
**Acceptance Scenarios**:
1. **Given** a valid event link, **When** a guest opens the URL, **Then** the page displays the event title, date and time, and attendee count.
2. **Given** a valid event link for an event with optional fields set, **When** a guest opens the URL, **Then** the description and location are also displayed.
3. **Given** a valid event link for an event with optional fields absent, **When** a guest opens the URL, **Then** only the required fields are shown — no placeholder text for missing optional fields.
4. **Given** a valid event with RSVPs, **When** a guest opens the event page, **Then** the names of all confirmed attendees ("attending") are listed and a total count is shown.
5. **Given** an event page, **When** it is rendered, **Then** no external resources (CDNs, fonts, tracking scripts) are loaded — all assets are served from the app's own domain.
6. **Given** a guest with no account, **When** they open the event URL, **Then** the page loads without any login, account, or access code required.
---
### User Story 2 - View expired event (Priority: P2)
A guest opens a shared event link after the event's expiry date has passed. The event details are still accessible but the page clearly communicates that the event has ended and RSVP actions are not available.
**Why this priority**: The expired state is a required UI behavior that derives directly from the mandatory expiry date in US-1. Displaying expired events incorrectly would mislead guests.
**Independent Test**: Can be tested by creating an event with a past expiry date (or advancing the system clock) and verifying the "event has ended" state renders without RSVP controls.
**Acceptance Scenarios**:
1. **Given** an event whose expiry date has passed, **When** a guest opens the event URL, **Then** the page displays a clear "this event has ended" state and no RSVP actions are shown.
---
### User Story 3 - View cancelled event (Priority: P2)
A guest opens a shared event link for an event that has been cancelled (US-18). The page clearly communicates the cancellation and optionally displays the organizer's cancellation message. No RSVP actions are shown. [Deferred until US-18 is implemented]
**Why this priority**: Cancellation is a distinct state from expiry; guests must not be misled into thinking they can still RSVP.
**Independent Test**: Can be tested once US-18 is implemented by cancelling an event and verifying the cancelled state renders correctly.
**Acceptance Scenarios**:
1. **Given** a cancelled event (US-18), **When** a guest opens the event URL, **Then** the page displays a clear "cancelled" state with the cancellation message if provided, and no RSVP actions are shown. [Deferred until US-18 is implemented]
---
### User Story 4 - Event not found (Priority: P2)
A guest navigates to an event URL that no longer resolves — the event was deleted by the organizer (US-19) or automatically removed after expiry (US-12). The page displays a clear "event not found" message with no partial data or error traces.
**Why this priority**: Correct handling of deleted events is a privacy requirement — no partial data may be served.
**Independent Test**: Can be tested by navigating to a URL with an unknown event token and verifying the "event not found" message renders.
**Acceptance Scenarios**:
1. **Given** an event token that does not match any event on the server, **When** a guest opens the URL, **Then** the page displays a clear "event not found" message — no partial data, no error traces, no stack dumps.
---
### Edge Cases
- What happens when the event has no attendees yet? — Attendee list is empty; count shows 0.
- What happens when the event has been cancelled after US-18 is implemented? — Renders cancelled state with optional message; RSVP hidden. [Deferred]
- What happens when the server is temporarily unavailable? — [NEEDS EXPANSION]
- How does the page behave when JavaScript is disabled? — Per Q-3 resolution: the app is a SPA; JavaScript-dependent rendering is acceptable.
## Requirements
### Functional Requirements
- **FR-001**: The event page MUST display: title, date and time, and attendee count for any valid event.
- **FR-002**: The event page MUST display description and location when those optional fields are set on the event.
- **FR-003**: The event page MUST list the names of all confirmed attendees (those who RSVPed "attending").
- **FR-004**: If the event's expiry date has passed, the page MUST render a clear "this event has ended" state and MUST NOT show any RSVP actions.
- **FR-005**: If the event has been cancelled (US-18), the page MUST display a "cancelled" state with the cancellation message (if provided) and MUST NOT show any RSVP actions. [Deferred until US-18 is implemented]
- **FR-006**: If the event token does not match any event on the server, the page MUST display a clear "event not found" message — no partial data or error traces.
- **FR-007**: The event page MUST be accessible without any login, account, or access code — only the event token in the URL is required.
- **FR-008**: The event page MUST NOT load any external resources (no CDNs, no Google Fonts, no tracking scripts).
### Key Entities
- **Event**: Has a public event token (UUID in URL), title, optional description, date/time, optional location, expiry date, and optionally a cancelled state with message.
- **RSVP**: Has a guest name and attending status; confirmed attendees (status = attending) are listed on the public event page.
## Success Criteria
### Measurable Outcomes
- **SC-001**: A guest who opens a valid event URL can see all set event fields (title, date/time, and any optional fields) without logging in.
- **SC-002**: The attendee list and count reflect all current server-side RSVPs with attending status.
- **SC-003**: An expired event URL renders the "ended" state — RSVP controls are absent from the DOM, not merely hidden via CSS.
- **SC-004**: An unknown event token URL renders a "not found" message — no event data, no server error details.
- **SC-005**: No network requests to external domains are made when loading the event page.

94
specs/008-rsvp/spec.md Normal file
View File

@@ -0,0 +1,94 @@
# Feature Specification: RSVP to an Event
**Feature**: `008-rsvp`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Submit an RSVP (Priority: P1)
A guest opens an active event page and indicates whether they will attend. If attending, they must provide their name. If not attending, the name is optional. The RSVP is sent to the server and persisted. The guest's choice, name, event token, title, and date are saved in localStorage.
**Why this priority**: Core interactive feature of the app. Without it, guests cannot communicate attendance, and the attendee list (US-2) has no data.
**Independent Test**: Can be fully tested by opening an event page and submitting "I'm attending" with a name, then verifying the attendee list updates and localStorage contains the RSVP record.
**Acceptance Scenarios**:
1. **Given** a guest is on an active event page, **When** they select "I'm attending" and enter their name, **Then** the RSVP is submitted to the server, persisted, and the attendee list reflects the new entry.
2. **Given** a guest is on an active event page, **When** they select "I'm attending" but leave the name blank, **Then** the form is not submitted and a validation message indicating the name is required is shown.
3. **Given** a guest is on an active event page, **When** they select "I'm not attending" without entering a name, **Then** the RSVP is submitted successfully (name is optional for non-attendees).
4. **Given** a guest submits an RSVP (attending or not), **When** the submission succeeds, **Then** the guest's RSVP choice, name, event token, event title, and event date are stored in localStorage on this device.
5. **Given** a guest submits an RSVP, **When** the submission succeeds, **Then** no account, login, or personal data beyond the optionally entered name is required.
---
### User Story 2 - Re-RSVP from the Same Device (Priority: P2)
A returning guest on the same device opens an event page where they previously submitted an RSVP. The form pre-fills with their prior choice and name. Re-submitting updates the existing RSVP rather than creating a duplicate.
**Why this priority**: Prevents duplicate entries and provides a better UX for guests who want to change their mind. Depends on Story 1 populating localStorage.
**Independent Test**: Can be tested by RSVPing once, then reloading the event page and verifying the form is pre-filled and a second submission updates rather than duplicates the server-side record.
**Acceptance Scenarios**:
1. **Given** a guest has previously submitted an RSVP on this device, **When** they open the same event page again, **Then** the RSVP form is pre-filled with their previous choice and name.
2. **Given** a guest has a prior RSVP pre-filled, **When** they change their selection and re-submit, **Then** the existing server-side RSVP entry is updated and no duplicate entry is created.
---
### User Story 3 - RSVP Blocked on Expired or Cancelled Events (Priority: P2)
A guest attempts to RSVP to an event that has already expired or has been cancelled. The RSVP form is not shown and the server rejects any submission attempts.
**Why this priority**: Enforces data integrity and respects the event lifecycle. Cancelled event guard deferred until US-18 is implemented.
**Independent Test**: Can be tested by attempting to RSVP to an event whose expiry date has passed and verifying the form is hidden and the server returns a rejection response.
**Acceptance Scenarios**:
1. **Given** an event's expiry date has passed, **When** a guest opens the event page, **Then** the RSVP form is not shown and no RSVP submission is possible.
2. **Given** an event's expiry date has passed, **When** a guest sends a direct RSVP request to the server, **Then** the server rejects the submission with a clear error response.
3. **Given** an event has been cancelled (US-18), **When** a guest opens the event page, **Then** the RSVP form is hidden and no RSVP submission is possible [deferred until US-18 is implemented].
---
### Edge Cases
- What happens when a guest RSVPs on two different devices? Each device stores its own localStorage entry; the server holds both RSVPs as separate entries (no deduplication across devices — acceptable per design, consistent with the no-account model).
- What happens when the server is unreachable during RSVP submission? The submission fails; localStorage is not updated (no optimistic write). The guest sees an error and can retry.
- What happens if localStorage is cleared after RSVPing? The form no longer pre-fills and the guest can re-submit; the server will create a new RSVP entry rather than update the old one.
## Requirements
### Functional Requirements
- **FR-001**: The RSVP form MUST offer exactly two choices: "I'm attending" and "I'm not attending".
- **FR-002**: When the guest selects "I'm attending", the name field MUST be required; submission MUST be blocked if the name is blank.
- **FR-003**: When the guest selects "I'm not attending", the name field MUST be optional; submission MUST succeed without a name.
- **FR-004**: On successful RSVP submission, the server MUST persist the RSVP associated with the event.
- **FR-005**: On successful RSVP submission, the client MUST store the guest's RSVP choice and name in localStorage, keyed by event token.
- **FR-006**: On successful RSVP submission, the client MUST store the event token, event title, and event date in localStorage (to support the local event overview, US-7).
- **FR-007**: If a prior RSVP for this event exists in localStorage, the form MUST pre-fill with the stored choice and name on page load.
- **FR-008**: Re-submitting an RSVP from a device that has an existing server-side entry for this event MUST update the existing entry, not create a new one.
- **FR-009**: The RSVP form MUST NOT be shown and the server MUST reject RSVP submissions after the event's expiry date has passed.
- **FR-010**: The RSVP form MUST NOT be shown and the server MUST reject RSVP submissions if the event has been cancelled [enforcement deferred until US-18 is implemented].
- **FR-011**: RSVP submission MUST NOT require an account, login, or any personal data beyond the optionally entered name.
- **FR-012**: No personal data or IP address MUST be logged on the server when processing an RSVP.
### Key Entities
- **RSVP**: Represents a guest's attendance declaration. Attributes: event token reference, attending status (boolean), optional name, creation/update timestamp. The server-side identity key for deduplication is the combination of event token and a device-bound identifier [NEEDS EXPANSION: deduplication mechanism to be defined during implementation].
## Success Criteria
### Measurable Outcomes
- **SC-001**: A guest can submit an RSVP (attending with name, or not attending without name) from the event page without an account.
- **SC-002**: Submitting an RSVP from the same device twice results in exactly one server-side RSVP entry for that guest (no duplicates).
- **SC-003**: After submitting an RSVP, the local event overview (US-7) can display the event without a server request (event token, title, and date are in localStorage).
- **SC-004**: The RSVP form is not shown on expired events, and direct server submissions for expired events are rejected.
- **SC-005**: No name, IP address, or personal data beyond the submitted name is stored or logged by the server in connection with an RSVP.

View File

@@ -0,0 +1,63 @@
# Feature Specification: Manage Guest List as Organizer
**Feature**: `009-guest-list`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - View and manage RSVPs (Priority: P1)
As an event organizer, I want to view all RSVPs for my event and remove individual entries if needed, so that I have an accurate overview of attendance and can moderate erroneous or spam entries.
The organizer view is accessible from the event page when a valid organizer token for that event is present in localStorage. When no organizer token is present, no organizer-specific UI is shown. The organizer can see each RSVP entry with name and attending status, and can permanently delete any entry.
**Why this priority**: Core organizer capability — without it, the organizer has no way to manage erroneous or spam RSVP entries. The public attendee list is only trustworthy if the organizer can moderate it.
**Independent Test**: Can be tested by creating an event (obtaining an organizer token), submitting several RSVPs via the RSVP form, then opening the organizer view to verify the list is displayed and deletion works.
**Acceptance Scenarios**:
1. **Given** an organizer token for an event is present in localStorage, **When** the organizer opens the event page, **Then** an organizer view link or section is visible that is not shown to guests without the token.
2. **Given** the organizer view is open, **When** the page loads, **Then** all RSVPs for the event are listed, each showing the entry's name and attending status.
3. **Given** the organizer view is open with at least one RSVP entry, **When** the organizer deletes an entry, **Then** the entry is permanently removed from the server and immediately disappears from the attendee list on the public event page.
4. **Given** a visitor without an organizer token in localStorage opens the event page, **When** the page renders, **Then** no organizer-specific UI (link, button, or organizer view) is visible.
5. **Given** the organizer view is open, **When** the organizer attempts to access it via a guessable URL without the organizer token in localStorage, **Then** organizer access is denied — it requires the organizer token established during event creation.
6. **Given** the organizer has a valid organizer token in localStorage, **When** the organizer accesses the organizer view, **Then** no additional authentication step beyond the localStorage token is required.
---
### Edge Cases
- What happens when the organizer token is present in localStorage but the event no longer exists on the server?
- How does the system handle a deletion request when the organizer token is invalid or has been cleared from localStorage mid-session?
- What if all RSVPs are deleted — does the organizer view show an empty state?
## Requirements
### Functional Requirements
- **FR-001**: System MUST display the organizer view (guest list management) only when a valid organizer token for that event is present in localStorage.
- **FR-002**: System MUST hide all organizer-specific UI (links, buttons, organizer view) from visitors who do not have the organizer token in localStorage.
- **FR-003**: Organizer view MUST list all RSVPs for the event, showing each entry's name and attending status.
- **FR-004**: Organizer MUST be able to permanently delete any individual RSVP entry from the organizer view.
- **FR-005**: System MUST reflect RSVP deletions immediately on the public event page — the attendee list must update without delay.
- **FR-006**: Organizer view MUST NOT be accessible via a guessable URL — access requires the organizer token stored in localStorage during event creation (US-1).
- **FR-007**: System MUST NOT require any additional authentication step beyond the presence of the organizer token in localStorage.
- **FR-008**: Server MUST reject RSVP deletion requests that do not include a valid organizer token.
### Key Entities
- **RSVP**: An entry submitted by a guest (US-3). Attributes: event association, guest name, attending status. The organizer can delete individual entries.
- **Organizer token**: A secret UUID stored in localStorage on the device where the event was created (US-1). Grants organizer access to the guest list management view.
## Success Criteria
### Measurable Outcomes
- **SC-001**: An organizer can view the complete guest list for their event from the event page without navigating away to a separate URL.
- **SC-002**: An organizer can delete any RSVP entry and the deletion is reflected on the public event page within the same page interaction (no reload required).
- **SC-003**: A visitor without the organizer token in localStorage sees no organizer UI at all — zero organizer-specific elements rendered.
- **SC-004**: The organizer view is not reachable by guessing or constructing a URL — it requires the in-memory/localStorage token to render.
- **SC-005**: No additional login, account, or authentication beyond the organizer token is required to manage the guest list.

View File

@@ -0,0 +1,89 @@
# Feature Specification: Edit Event Details as Organizer
**Feature**: `010-edit-event`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Edit event details (Priority: P1)
The event organizer wants to update the details of an event they created — title, description, date/time, location, or expiry date — so that guests always see accurate and up-to-date information if something changes.
**Why this priority**: Core organizer capability. Edits are expected for any real-world event (date changes, venue updates). Without this, the organizer has no recourse when details change after creation.
**Independent Test**: Can be fully tested by creating an event (US-1), navigating to the edit form, submitting changed values, and verifying the event page reflects the updates.
**Acceptance Scenarios**:
1. **Given** an organizer on the event page with a valid organizer token in localStorage, **When** they navigate to the edit form, **Then** the form is pre-filled with the current event values (title, description, date/time, location, expiry date).
2. **Given** the edit form is pre-filled, **When** the organizer modifies one or more fields and submits, **Then** the changes are persisted server-side and the organizer is returned to the event page which reflects the updated details.
3. **Given** the organizer is on the event page without an organizer token in localStorage, **When** they attempt to access the edit UI, **Then** no edit option is shown and the server rejects any update request.
---
### User Story 2 - Expiry date future-date validation (Priority: P2)
When editing, the organizer must set the expiry date to a future date. Setting it to today or a past date is rejected, and the organizer is directed to the delete feature (US-19) if they want to remove the event immediately.
**Why this priority**: Enforces the invariant that an event's expiry date is always in the future. Prevents the expiry date field from being misused as an implicit deletion mechanism, keeping the model clean.
**Independent Test**: Can be tested by submitting the edit form with a past or current date in the expiry date field and verifying the rejection response and validation message.
**Acceptance Scenarios**:
1. **Given** the edit form is open, **When** the organizer sets the expiry date to today or a past date and submits, **Then** the submission is rejected with a clear validation message directing the organizer to use the delete feature (US-19) instead.
2. **Given** the edit form is open, **When** the organizer sets the expiry date to a date in the future and submits, **Then** the change is accepted and persisted.
---
### User Story 3 - Organizer token authentication (Priority: P2)
If the organizer token is absent or invalid, neither the edit UI is shown nor the edit request is accepted server-side.
**Why this priority**: Security constraint — the organizer token is the sole authentication mechanism. Both client and server must enforce it independently.
**Independent Test**: Can be tested by attempting an edit request with a missing or wrong organizer token and verifying a 403/401 response and no UI exposure.
**Acceptance Scenarios**:
1. **Given** a visitor without an organizer token in localStorage for the event, **When** they view the event page, **Then** no edit option or link is shown.
2. **Given** an edit request is submitted with an absent or invalid organizer token, **When** the server processes the request, **Then** it rejects the request and the event data is unchanged.
---
### Edge Cases
- What happens when the organizer submits the edit form with no changes? The server accepts the submission (idempotent update) and the organizer is returned to the event page.
- What happens if the title field is left empty? Submission is rejected with a validation message — title is required.
- What happens if the event has expired before the organizer submits the edit form? The server rejects the edit — editing an expired event is not permitted.
- How does the system handle concurrent edits (e.g. organizer edits from two devices simultaneously)? [NEEDS EXPANSION — last-write-wins is the simplest strategy]
## Requirements
### Functional Requirements
- **FR-001**: System MUST allow the organizer to edit: title (required), description (optional), date and time (required), location (optional), expiry date (required).
- **FR-002**: System MUST pre-fill the edit form with the current stored values for all editable fields.
- **FR-003**: System MUST reject an edit submission where the expiry date is today or in the past, and MUST return a validation message directing the organizer to use the delete feature (US-19).
- **FR-004**: System MUST persist all submitted changes server-side upon successful validation.
- **FR-005**: System MUST redirect the organizer to the event page after a successful edit, with the updated details visible.
- **FR-006**: System MUST NOT expose the edit UI to any visitor who does not have a valid organizer token for the event in localStorage.
- **FR-007**: System MUST reject any edit request where the organizer token is absent or does not match the event's stored organizer token.
- **FR-008**: No account or additional authentication step beyond the organizer token is required to edit an event.
### Key Entities
- **Event**: Mutable entity with fields: title, description, date/time, location, expiry date. Identified externally by its event token. Updated via the organizer token.
- **Organizer token**: Secret UUID stored in localStorage on the device where the event was created. Required to authenticate all organizer operations including editing.
## Success Criteria
### Measurable Outcomes
- **SC-001**: An organizer can update any editable field and see the change reflected on the public event page without a page reload after redirect.
- **SC-002**: Any attempt to set the expiry date to a non-future date is rejected with a user-visible validation message before the server persists the change.
- **SC-003**: No edit operation is possible — client or server — without a valid organizer token.
- **SC-004**: The edit form is never shown to a visitor who does not hold the organizer token for the event.
- **SC-005**: Visual highlighting of changed fields for guests is deferred to US-9 (Highlight changed event details); this story covers only the server-side persistence and organizer UX of editing.

View File

@@ -0,0 +1,90 @@
# Feature Specification: Bookmark an Event
**Feature**: `011-bookmark-event`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
> **Note on directory naming**: The migration task list labeled this directory `us-06-calendar-export`, but US-6 in userstories.md is "Bookmark an event". Calendar export is US-8. The directory was created with the correct name reflecting the actual story content.
## User Scenarios & Testing
### User Story 1 - Bookmark an event without RSVP (Priority: P1)
A guest who has opened an event page wants to save it for later without committing to attendance. They activate a "Remember" / "Follow" action on the event page. The event token, title, and date are stored in localStorage — no server request is made. The bookmark persists across browser sessions on the same device. The guest can unfollow by activating the action again.
**Why this priority**: Core bookmarking capability — without this, the entire feature has no value.
**Independent Test**: Can be fully tested by visiting an event page, activating the bookmark action, closing the browser, and reopening to verify persistence. Delivers value by enabling the local event overview (US-7) to display bookmarked events.
**Acceptance Scenarios**:
1. **Given** a guest has opened an event page, **When** they activate the "Remember" / "Follow" action, **Then** the event token, title, and date are stored in localStorage and no server request is made.
2. **Given** a guest has bookmarked an event, **When** they close and reopen the browser, **Then** the bookmark is still present in localStorage.
3. **Given** a guest has bookmarked an event, **When** they activate the bookmark action again, **Then** the bookmark is removed from localStorage ("unfollow") without any server contact.
4. **Given** a guest has bookmarked an event, **When** the event page is loaded again, **Then** the bookmark action reflects the current bookmarked state.
---
### User Story 2 - Bookmark is independent of RSVP state (Priority: P2)
A guest who has already RSVPed to an event on their device can still explicitly bookmark or un-bookmark the event. The bookmark state is tracked separately from RSVP state.
**Why this priority**: Important for correctness but not the primary use case. The primary scenario is the undecided guest who wants to remember without committing.
**Independent Test**: Can be tested by RSVPing to an event and then toggling the bookmark action — both states persist independently in localStorage.
**Acceptance Scenarios**:
1. **Given** a guest has RSVPed "attending" on this device, **When** they activate the bookmark action, **Then** the bookmark is stored independently and the RSVP state is unaffected.
2. **Given** a guest has bookmarked an event and RSVPed, **When** they remove the bookmark, **Then** the RSVP state is unaffected.
---
### User Story 3 - Bookmark available on expired events (Priority: P2)
A guest who is viewing an event that has passed its expiry date can still bookmark it, so it remains visible in their local event overview (US-7).
**Why this priority**: Edge case that ensures continuity of the local overview even for past events.
**Independent Test**: Can be tested by visiting an expired event page and verifying the bookmark action is present and functional.
**Acceptance Scenarios**:
1. **Given** an event has passed its expiry date, **When** a guest views the event page, **Then** the bookmark action is still shown and functional.
2. **Given** a guest bookmarks an expired event, **When** they view their local event overview (US-7), **Then** the event appears in the list marked as ended.
---
### Edge Cases
- What happens when the event title or date changes after bookmarking? Locally cached title and date may become stale if the organizer edits the event — this is an accepted trade-off. Cached values are refreshed when the guest next visits the event page.
- How does the app handle localStorage being unavailable (e.g. private browsing in some browsers)? [NEEDS EXPANSION]
- What happens if the guest bookmarks the same event from multiple devices? Each device maintains its own independent bookmark — no server-side sync.
## Requirements
### Functional Requirements
- **FR-001**: The event page MUST display a "Remember" / "Follow" action accessible to any visitor holding the event link.
- **FR-002**: Activating the bookmark action MUST store the event token, event title, and event date in localStorage with no server request.
- **FR-003**: The bookmark MUST persist across browser sessions on the same device.
- **FR-004**: A second activation of the bookmark action MUST remove the bookmark ("unfollow") with no server contact.
- **FR-005**: The bookmark state MUST be independent of the RSVP state — both can coexist for the same event on the same device.
- **FR-006**: The bookmark action MUST remain available on event pages where the event has expired.
- **FR-007**: No personal data, IP address, or identifier MUST be transmitted to the server when bookmarking or un-bookmarking.
- **FR-008**: The bookmark action MUST reflect the current state (bookmarked / not bookmarked) when the event page loads.
### Key Entities
- **Bookmark record** (localStorage): Stored per event token. Contains: event token, event title, event date. Indicates the guest has explicitly bookmarked this event without RSVPing. Independent of the RSVP record.
## Success Criteria
### Measurable Outcomes
- **SC-001**: A guest can bookmark an event and find it in their local overview (US-7) without any server contact at any point in the bookmark flow.
- **SC-002**: Bookmarking and un-bookmarking produce no network requests (verifiable via browser DevTools).
- **SC-003**: A bookmark persists after browser restart on the same device.
- **SC-004**: The bookmark state is correctly reflected on the event page across multiple sessions.
- **SC-005**: Guests with existing RSVPs on this device can independently toggle the bookmark without affecting their RSVP state.

View File

@@ -0,0 +1,111 @@
# Feature Specification: Local Event Overview List
**Feature**: `012-local-event-overview`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - View tracked events on the root page (Priority: P1)
A user who has created, bookmarked, or RSVPed to events on this device opens the root page (`/`) and sees a list of all those events, each with the event title, date, and their relationship to the event (organizer / attending / not attending / bookmarked only). Each entry is a link to the event page. The list is rendered entirely from localStorage — no server request is required. If no events are tracked locally, an empty state is shown.
**Why this priority**: This is the core feature — the reason the overview exists. Without this story, no other scenario is meaningful.
**Independent Test**: Can be fully tested by seeding localStorage with event entries (simulating US-1/US-3/US-6 data) and loading the root page. Delivers value by allowing users to navigate back to any tracked event without the original link.
**Acceptance Scenarios**:
1. **Given** the user has created an event from this device (organizer token in localStorage), **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "organizer"
2. **Given** the user has RSVPed "attending" to an event from this device, **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "attending"
3. **Given** the user has RSVPed "not attending" to an event from this device, **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "not attending"
4. **Given** the user has bookmarked an event from this device (US-6), **When** they navigate to `/`, **Then** the event appears in the list with title, date, and relationship "bookmarked only"
5. **Given** no events are tracked in localStorage, **When** the user navigates to `/`, **Then** an empty state message is shown — not an error
6. **Given** the list is rendered, **When** the user clicks an entry, **Then** they are navigated directly to the event page for that entry
---
### User Story 2 - Visually distinguish past events (Priority: P2)
Events whose date has passed are still shown in the list but rendered with a visual distinction (e.g. marked as "ended"), so the user can differentiate between upcoming and past events at a glance.
**Why this priority**: Useful for UX clarity but the overview is still functional without this distinction. Past events in the list are still navigable.
**Independent Test**: Can be tested by placing a past-dated event entry in localStorage and loading the root page. The entry should appear visually distinct from current events.
**Acceptance Scenarios**:
1. **Given** an event in localStorage has a date in the past, **When** the user views the overview, **Then** the entry is visually distinguished (e.g. marked "ended") compared to upcoming events
2. **Given** an event in localStorage has a date in the future, **When** the user views the overview, **Then** the entry is rendered without any "ended" indicator
---
### User Story 3 - Remove an entry from the local list (Priority: P2)
The user can remove individual entries from the local overview. The behavior depends on the entry type: for bookmarked-only events the bookmark is removed; for RSVPed events the local record is removed (server-side RSVP unaffected); for organizer-created events the organizer token and event data are removed from localStorage, with a confirmation warning that organizer access on this device will be lost.
**Why this priority**: Important for list hygiene but not required for the core navigation use case.
**Independent Test**: Can be tested by placing entries of each type in localStorage and verifying removal behavior from the overview UI.
**Acceptance Scenarios**:
1. **Given** a bookmarked-only event is in the local list, **When** the user removes that entry, **Then** the bookmark is removed from localStorage and the entry disappears from the list
2. **Given** an RSVPed event is in the local list, **When** the user removes that entry, **Then** the local RSVP record is removed from localStorage; the server-side RSVP is unaffected
3. **Given** an organizer-created event is in the local list, **When** the user initiates removal, **Then** a confirmation warning is shown explaining that organizer access on this device will be revoked
4. **Given** the organizer confirms removal, **Then** the organizer token and event metadata are removed from localStorage and the entry disappears from the list
---
### User Story 4 - Handle a deleted event when navigating from the overview (Priority: P2)
If the user clicks an entry in the local overview and the server responds that the event no longer exists (deleted per US-12 automatic cleanup or US-19 organizer deletion), the app displays an "event no longer exists" message and offers to remove the entry from the local list.
**Why this priority**: Edge case that improves consistency and prevents stale entries from accumulating, but not core to the overview's primary purpose.
**Independent Test**: Can be tested by navigating to an event whose token does not exist on the server. The app should display a "no longer exists" message and offer removal.
**Acceptance Scenarios**:
1. **Given** an entry exists in the local overview, **When** the user navigates to that event and the server returns "event not found", **Then** the app displays an "event no longer exists" message and offers to remove the entry from the local list
2. **Given** the user confirms removal of the stale entry, **Then** the entry is removed from localStorage and the user is returned to the overview
---
### Edge Cases
- What happens if localStorage is unavailable or disabled in the browser? The overview cannot render — [NEEDS EXPANSION: define fallback message or behavior]
- What happens if the same event appears under multiple localStorage keys (e.g. both RSVPed and organizer)? [NEEDS EXPANSION: define de-duplication or priority rule for relationship label]
- What happens if an event's locally cached title or date is stale (organizer edited via US-5)? Stale values are displayed until the user next visits the event page — this is an accepted trade-off per the story notes.
- What happens when the user has a very large number of tracked events? [NEEDS EXPANSION: pagination or truncation strategy]
## Requirements
### Functional Requirements
- **FR-001**: The root page (`/`) MUST display the local event overview list below a project header/branding section
- **FR-002**: The list MUST include any event for which an organizer token, RSVP record, or bookmark exists in localStorage for this device
- **FR-003**: Each list entry MUST show at minimum: event title, event date, and the user's relationship to the event (organizer / attending / not attending / bookmarked only)
- **FR-004**: Each list entry MUST be a link that navigates directly to the corresponding event page
- **FR-005**: The list MUST be populated entirely from localStorage — no server request is made to render the overview
- **FR-006**: Events whose date has passed MUST be visually distinguished (e.g. marked "ended") from upcoming events
- **FR-007**: An individual entry MUST be removable from the list; removal behavior depends on entry type (bookmark removal, local RSVP record removal, or organizer token removal)
- **FR-008**: Removing an organizer-created event entry MUST require a confirmation warning explaining that organizer access on this device will be revoked
- **FR-009**: No personal data or event data MUST be transmitted to the server when viewing or interacting with the overview
- **FR-010**: If no events are tracked locally, an empty state MUST be shown — not an error or blank screen
- **FR-011**: When navigating from the overview to an event that the server reports as deleted, the app MUST display an "event no longer exists" message and offer to remove the stale entry from the local list
### Key Entities
- **LocalEventEntry**: A localStorage-stored record representing one tracked event. Contains at minimum: event token, event title, event date, and relationship type (organizer / attending / not attending / bookmarked). May also contain: organizer token (for organizer entries), RSVP choice and name (for RSVP entries).
## Success Criteria
### Measurable Outcomes
- **SC-001**: A user with events tracked in localStorage can navigate to `/` and see all tracked events without any server request being made
- **SC-002**: Each event entry links correctly to its event page
- **SC-003**: Past events are visually distinguishable from upcoming events in the list
- **SC-004**: An entry can be removed from the list, and the corresponding localStorage key is cleaned up correctly for each entry type
- **SC-005**: The empty state is shown when no events are tracked in localStorage — no blank page or error state

View File

@@ -0,0 +1,92 @@
# Feature Specification: Add Event to Calendar
**Feature**: `013-calendar-export`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Download .ics file (Priority: P1)
A guest who wants to remember the event opens the event page and downloads an iCalendar file to import into their personal calendar application. The file contains all relevant event details: title, description, start date/time, location, and the public event URL. The UID in the file is derived from the event token so that re-downloading the file and re-importing it updates the existing calendar entry rather than creating a duplicate.
**Why this priority**: Core calendar integration — the most common and universally supported way to add an event to a calendar across all platforms.
**Independent Test**: Can be fully tested by visiting a valid event page, clicking the `.ics` download link, and verifying the downloaded file is a valid iCalendar (RFC 5545) document with the correct event fields and a stable UID.
**Acceptance Scenarios**:
1. **Given** a valid event page, **When** the guest clicks the `.ics` download link, **Then** a standards-compliant iCalendar file is downloaded containing the event title, description (if present), start date and time, location (if present), the public event URL, and a unique UID derived from the event token.
2. **Given** the guest downloads the `.ics` file twice for the same event, **When** they import both files into a calendar application, **Then** the calendar application updates the existing entry rather than creating a duplicate (due to the stable UID).
3. **Given** a guest holding only the event link (no RSVP, no login), **When** they access the `.ics` download link, **Then** the file is served without requiring any authentication or personal data.
4. **Given** an event whose expiry date has passed, **When** the guest accesses the `.ics` download link, **Then** the file is still served (the guest can still obtain the calendar record of a past event).
---
### User Story 2 - Subscribe via webcal:// link (Priority: P2)
A guest subscribes to the event via a `webcal://` link so that their calendar application automatically picks up any changes — such as a rescheduled date or updated location — when the organizer edits the event via US-5.
**Why this priority**: Adds live-update value on top of the static `.ics` download. Requires the `.ics` download (P1) to already work. Most useful in conjunction with US-5 (Edit event details).
**Independent Test**: Can be tested by opening the `webcal://` URL in a calendar application and verifying it subscribes to the feed and shows the correct event details.
**Acceptance Scenarios**:
1. **Given** a valid event page, **When** the guest clicks the `webcal://` subscription link, **Then** their calendar application subscribes to the feed and displays the event.
2. **Given** a subscribed `webcal://` feed and an organizer who edits the event date via US-5, **When** the calendar application syncs the feed, **Then** the calendar entry is updated to reflect the new date.
3. **Given** a `webcal://` endpoint, **When** it is accessed, **Then** it serves identical iCalendar content as the `.ics` download, using the same event token in the URL.
---
### User Story 3 - Cancelled event reflected in calendar (Priority: P3)
When an event is cancelled (US-18), the `.ics` file and `webcal://` feed include `STATUS:CANCELLED` so that subscribed calendar applications reflect the cancellation automatically on their next sync.
**Why this priority**: Quality-of-life enhancement dependent on US-18 (cancel event). Deferred until US-18 is implemented.
**Independent Test**: Can be tested by cancelling an event and verifying the calendar feed includes `STATUS:CANCELLED`.
**Acceptance Scenarios**:
1. **Given** an event that has been cancelled (US-18), **When** a calendar application syncs the `webcal://` feed, **Then** the calendar entry is updated to show the cancelled status.
2. **Given** an event that has been cancelled (US-18), **When** the guest downloads the `.ics` file, **Then** it includes `STATUS:CANCELLED`.
> **Note**: Deferred until US-18 is implemented.
---
### Edge Cases
- What happens when the event has no description or location? The `.ics` file must omit those optional fields rather than including blank values.
- What happens if the server has no public URL configured? The event URL included in the `.ics` file must always be the correct public event URL.
- What happens if the event's date or timezone changes after a guest already imported the `.ics` file? The static import will be stale; the `webcal://` subscription will auto-update on next sync.
## Requirements
### Functional Requirements
- **FR-001**: System MUST expose a server-side endpoint that generates and serves a standards-compliant iCalendar (RFC 5545) `.ics` file for any event identified by its event token.
- **FR-002**: The `.ics` file MUST include: event title, description (if present), start date and time, location (if present), the public event URL, and a unique UID derived from the event token.
- **FR-003**: The UID in the `.ics` file MUST be stable across regenerations (same event token always produces the same UID) so that calendar applications update existing entries on re-import rather than creating duplicates.
- **FR-004**: The `.ics` file MUST be generated server-side; no external calendar or QR code service is called.
- **FR-005**: System MUST expose a `webcal://` subscription endpoint that serves identical iCalendar content as the `.ics` download, using the same event token in the URL.
- **FR-006**: Both the `.ics` download link and the `webcal://` subscription link MUST be accessible to any visitor holding the event link — no RSVP, login, or personal data required.
- **FR-007**: No personal data, name, or IP address MUST be logged when either link is accessed.
- **FR-008**: Both links MUST remain available and functional after the event's expiry date has passed.
- **FR-009**: When an event is cancelled (US-18), the `.ics` file and `webcal://` feed MUST include `STATUS:CANCELLED`. [Deferred until US-18 is implemented]
### Key Entities
- **CalendarFeed**: A virtual resource derived from the Event entity. Identified by the event token. Serialized to iCalendar (RFC 5545) format on demand. Has no independent storage — always generated from current event state.
## Success Criteria
### Measurable Outcomes
- **SC-001**: Downloading the `.ics` file and importing it into a standard calendar application (Google Calendar, Apple Calendar, Outlook) results in the event appearing with correct title, date/time, and location.
- **SC-002**: Re-importing the `.ics` file after an event edit updates the existing calendar entry rather than creating a duplicate.
- **SC-003**: Subscribing via `webcal://` and triggering a calendar sync after an event edit (US-5) reflects the updated details in the calendar application.
- **SC-004**: Both endpoints are accessible without authentication and without transmitting any personal data.
- **SC-005**: No external service is contacted during `.ics` generation or `webcal://` feed serving.

View File

@@ -0,0 +1,107 @@
# Feature Specification: Highlight Changed Event Details
**Feature**: `014-highlight-changes`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
> **NOTE on directory name**: The migration task list specified `us-09-reminders` for this directory, but US-9 in userstories.md is "Highlight changed event details". The directory has been created as `014-highlight-changes` to match the actual story content. This is consistent with corrections made in iterations 16 (us-06-bookmark-event) and 18 (us-08-calendar-export).
## User Scenarios & Testing
### User Story 1 - Guest sees highlight for recently changed fields (Priority: P1)
A guest opens an event page that the organizer has edited since the guest's last visit. Changed fields (e.g. new date, new location) are visually highlighted so the guest immediately notices what is different. After the page loads, the highlight is cleared for the next visit.
**Why this priority**: Core value of the feature — guests must notice important updates like a rescheduled date or changed location without having to read the entire page again.
**Independent Test**: Can be fully tested by creating an event, visiting it (establishing `last_seen_at`), editing it as organizer, then revisiting as guest — changed fields appear highlighted; unmodified fields do not.
**Acceptance Scenarios**:
1. **Given** a guest has previously visited an event page (establishing `last_seen_at` in localStorage), **When** the organizer saves an edit that changes one or more fields, **Then** on the guest's next visit those changed fields are visually highlighted with a "recently changed" indicator.
2. **Given** an event with an edit, **When** a guest opens the event page and the `last_edited_at` timestamp is newer than the stored `last_seen_at`, **Then** only the fields changed in the most recent edit are highlighted; unmodified fields are not highlighted.
3. **Given** a guest who has seen the latest edit, **When** they visit the event page again without any new edits having occurred, **Then** no highlights are shown.
---
### User Story 2 - No highlight on first visit (Priority: P2)
A guest opens an event page for the first time (no `last_seen_at` in localStorage). Even if the organizer has made edits since creation, no "recently changed" highlights are shown — the event is new to the guest, so labelling fields as changed would be misleading.
**Why this priority**: Correctness requirement. Showing highlights on first visit would be confusing because the guest has no reference point for what "changed" means.
**Independent Test**: Can be tested by clearing localStorage and opening an edited event page — no highlight indicators should appear.
**Acceptance Scenarios**:
1. **Given** no `last_seen_at` value in localStorage for a given event token, **When** a guest opens the event page, **Then** no field highlights are shown regardless of whether the organizer has made edits.
2. **Given** a first visit with no `last_seen_at`, **When** the event page is rendered, **Then** `last_seen_at` is written to localStorage with the current `last_edited_at` value, so the next visit will correctly compare against it.
---
### User Story 3 - Highlight clears after viewing (Priority: P2)
After the guest views the highlighted changes, the highlight is cleared on the next visit. Subsequent visits to the same event page (without new edits) show no highlights.
**Why this priority**: Without this, the highlight would become permanent noise rather than a meaningful "new change" signal.
**Independent Test**: Can be tested by visiting an event page with a change (seeing highlights), then visiting again — highlights should be gone.
**Acceptance Scenarios**:
1. **Given** a guest views an event page with highlighted fields, **When** the page is rendered, **Then** `last_seen_at` in localStorage is updated to match the current `last_edited_at`.
2. **Given** `last_seen_at` was updated on the previous visit, **When** the guest visits the event page again (with no new edits), **Then** no highlights are shown.
---
### User Story 4 - Only most recent edit is tracked (Priority: P3)
If the organizer makes multiple successive edits, only the fields changed in the most recent edit are highlighted. Intermediate changes between visits are not accumulated.
**Why this priority**: Simplicity constraint — tracking the full change history is overengineered for this scope. Guests see what changed last, not everything that ever changed.
**Independent Test**: Can be tested by making two successive edits to different fields, then visiting as a guest — only fields from the second edit are highlighted.
**Acceptance Scenarios**:
1. **Given** an organizer edits the event twice (first changing title, then changing location), **When** a guest visits the page after both edits, **Then** only the location is highlighted (changed in the most recent edit); title is not highlighted (changed in an earlier edit).
2. **Given** an event with no edits since creation, **When** any guest visits the event page, **Then** no highlights are shown.
---
### Edge Cases
- What if the organizer edits the event while the guest has the page open? The highlight logic runs on page load; open-page state is stale and will be corrected on the next visit.
- What if localStorage is unavailable (e.g. private browsing)? No `last_seen_at` can be stored, so the guest is treated as a first-time visitor and no highlights are shown. This is safe and graceful.
- What if `last_edited_at` is null (event has never been edited)? No highlights are shown. The field-change metadata is only populated on the first actual edit.
## Requirements
### Functional Requirements
- **FR-001**: System MUST record which fields changed (title, description, date/time, location) and store a `last_edited_at` timestamp server-side whenever the organizer saves an edit (US-5).
- **FR-002**: System MUST include `last_edited_at` and the set of changed field names in the event page API response.
- **FR-003**: Client MUST store a `last_seen_at` value per event token in localStorage, set to the event's `last_edited_at` on each page render.
- **FR-004**: Client MUST compare the event's `last_edited_at` against the stored `last_seen_at` on page load to determine whether highlights should be shown.
- **FR-005**: Client MUST display a "recently changed" visual indicator next to each field that appears in the server's changed-fields set, only when `last_edited_at` is newer than `last_seen_at`.
- **FR-006**: Client MUST NOT show any highlights when no `last_seen_at` is present in localStorage for the event (first visit).
- **FR-007**: Client MUST NOT show any highlights when `last_edited_at` is null or equal to `last_seen_at`.
- **FR-008**: Client MUST update `last_seen_at` in localStorage after rendering the event page, regardless of whether highlights were shown.
- **FR-009**: System MUST NOT transmit any visit data or `last_seen_at` value to the server — the read-state tracking is entirely client-side.
- **FR-010**: System MUST track only the most recent edit's changed fields; intermediate changes between visits are not accumulated.
### Key Entities
- **EditMetadata** (server-side): Records `last_edited_at` timestamp and the set of changed field names for an event. Associated with the event record. Populated on first edit; overwritten on each subsequent edit.
- **last_seen_at** (client-side, localStorage): Per-event-token timestamp. Records when the guest last viewed the event page. Used to determine whether highlights should be shown. Never transmitted to the server.
## Success Criteria
### Measurable Outcomes
- **SC-001**: A guest who has visited an event page before an edit correctly sees highlight indicators on the changed fields when revisiting after the edit.
- **SC-002**: A guest who visits an event page for the first time sees no highlight indicators, even if edits have been made.
- **SC-003**: Highlights disappear on the guest's next visit after they have viewed the highlighted changes.
- **SC-004**: No server request beyond the normal event page load is required to determine whether highlights should be shown.
- **SC-005**: No visit data or read-state information is transmitted to the server — privacy is fully preserved.

View File

@@ -0,0 +1,74 @@
# Feature Specification: Post Update Messages as Organizer
**Feature**: `015-organizer-updates`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Post and manage update messages (Priority: P1)
As an event organizer, I want to post short update messages on the event page and manage them, so that guests are informed of announcements or notes without requiring a separate communication channel.
**Why this priority**: The ability to post and display update messages is the core capability of this feature. Without it, nothing else in this story is testable.
**Independent Test**: Can be tested by creating an event, posting an update message via the organizer view, and verifying the message appears on the public event page.
**Acceptance Scenarios**:
1. **Given** a valid organizer token is present in localStorage, **When** the organizer submits a plain-text update message, **Then** the message is stored server-side with a timestamp and appears on the public event page in reverse chronological order.
2. **Given** multiple update messages have been posted, **When** a guest opens the event page, **Then** all messages are displayed newest-first, each with a human-readable timestamp.
3. **Given** a valid organizer token is present in localStorage, **When** the organizer deletes a previously posted update message, **Then** the message is permanently removed and no longer appears on the public event page.
4. **Given** no organizer token is present in localStorage, **When** a visitor views the event page, **Then** no compose or delete UI is shown and the server rejects any attempt to post or delete update messages.
---
### User Story 2 - Block posting after event expiry (Priority: P2)
As an event organizer, I want to be prevented from posting update messages after the event's expiry date has passed, so that the system remains consistent with the event lifecycle.
**Why this priority**: Expiry enforcement is a consistency constraint on top of the core posting capability.
**Independent Test**: Can be tested by attempting to post an update message via the API after an event's expiry date and verifying a rejection response is returned.
**Acceptance Scenarios**:
1. **Given** an event has passed its expiry date, **When** the organizer attempts to submit an update message, **Then** the server rejects the request and the message is not stored.
---
### Edge Cases
- What happens if the organizer submits an empty or whitespace-only update message?
- What is the maximum length of an update message? [NEEDS EXPANSION]
- How many update messages can an event accumulate? [NEEDS EXPANSION]
- Cancelled events (US-18): posting update messages is not blocked by cancellation, only by expiry — the organizer may want to post post-cancellation communication (e.g. a rescheduling notice or explanation).
## Requirements
### Functional Requirements
- **FR-001**: System MUST allow the organizer to compose and submit a plain-text update message from the organizer view when a valid organizer token is present in localStorage.
- **FR-002**: System MUST store each submitted update message server-side, associated with the event, with a server-assigned timestamp at the time of posting.
- **FR-003**: System MUST display all update messages for an event on the public event page in reverse chronological order (newest first), each with a human-readable timestamp.
- **FR-004**: System MUST reject any attempt to post an update message after the event's expiry date has passed.
- **FR-005**: System MUST allow the organizer to permanently delete any previously posted update message from the organizer view.
- **FR-006**: System MUST immediately remove a deleted update message from the public event page upon deletion.
- **FR-007**: System MUST reject any attempt to post or delete update messages when the organizer token is absent or invalid.
- **FR-008**: System MUST NOT show the compose or delete UI to visitors who do not have a valid organizer token in localStorage.
- **FR-009**: System MUST NOT log personal data or IP addresses when update messages are fetched or posted.
### Key Entities
- **UpdateMessage**: A plain-text message associated with an event. Key attributes: event reference, message body (plain text), created_at timestamp. Owned by the event; deleted when the event is deleted (US-12, US-19) or manually removed by the organizer.
## Success Criteria
### Measurable Outcomes
- **SC-001**: A posted update message appears on the public event page without requiring a page reload beyond the normal navigation.
- **SC-002**: Deleting an update message removes it from the public event page immediately upon deletion confirmation.
- **SC-003**: An attempt to post an update message without a valid organizer token returns a 4xx error response from the server.
- **SC-004**: An attempt to post an update message after the event's expiry date returns a 4xx error response from the server.
- **SC-005**: No IP addresses or personal data appear in server logs when update messages are fetched or posted.

View File

@@ -0,0 +1,84 @@
# Feature Specification: New-Update Indicator for Guests
**Feature**: `016-guest-notifications`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Unread update indicator (Priority: P1)
A guest opens the event page and sees a visual indicator (badge or highlighted section) drawing attention to update messages that were posted since their last visit. The read state is tracked entirely in localStorage — no server involvement.
**Why this priority**: Core purpose of this feature. Without this, guests miss new organizer announcements unless they manually read through all messages.
**Independent Test**: Can be tested by opening an event page after new update messages have been posted, verifying that a badge or visual highlight appears on the update messages section.
**Acceptance Scenarios**:
1. **Given** a guest has previously visited the event page and `updates_last_seen_at` is stored in localStorage, **When** they return and the event has updates newer than `updates_last_seen_at`, **Then** a visual indicator is shown drawing attention to the unread messages.
2. **Given** the event page is rendered with unread updates shown, **When** the page finishes loading, **Then** `updates_last_seen_at` in localStorage is updated to the timestamp of the most recent update, so the indicator does not appear on the next visit.
3. **Given** a guest opens the event page and all updates are older than the stored `updates_last_seen_at`, **When** the page loads, **Then** no "new update" indicator is shown.
---
### User Story 2 - First visit: no indicator (Priority: P2)
A guest who has never visited the event page before (no `updates_last_seen_at` in localStorage) sees the update messages without any "new" badge or indicator.
**Why this priority**: A first-time visitor has not established a baseline; labeling existing updates as "new" would be misleading since they have never seen the event before.
**Independent Test**: Can be tested by opening an event page on a device with no prior localStorage state for that event token, verifying that no unread indicator is shown even if update messages are present.
**Acceptance Scenarios**:
1. **Given** a guest opens an event page for the first time (no `updates_last_seen_at` in localStorage for this event token), **When** the page loads and update messages are present, **Then** no "new update" indicator is shown and `updates_last_seen_at` is initialized to the most recent update timestamp.
---
### User Story 3 - No server read-tracking (Priority: P1)
No server request is made to record that a guest viewed the updates. The read state is purely client-side.
**Why this priority**: Fundamental privacy requirement — tracking which guests have read which updates would be a form of user surveillance, violating the project's privacy statutes.
**Independent Test**: Can be tested by inspecting network traffic when a guest opens the event page, verifying that no "mark as read" or analytics request is sent.
**Acceptance Scenarios**:
1. **Given** a guest opens the event page with unread updates, **When** the page loads and `updates_last_seen_at` is updated in localStorage, **Then** no additional server request is made to record the read event.
---
### Edge Cases
- What happens when all update messages are deleted (US-10a) and a guest reopens the page? The stored `updates_last_seen_at` should remain in localStorage; no indicator is shown since there are no updates to compare against.
- What happens if localStorage is unavailable (private browsing, storage quota exceeded)? The indicator is not shown (degrades gracefully); no error is displayed to the user.
- The `updates_last_seen_at` key is separate from the `last_seen_at` key used in US-9 (field-change highlights). The two mechanisms operate independently.
## Requirements
### Functional Requirements
- **FR-001**: System MUST display a visual indicator (badge or highlighted section) on the event page when the guest has unread update messages, determined by comparing the newest update's timestamp against `updates_last_seen_at` stored in localStorage.
- **FR-002**: System MUST store the `updates_last_seen_at` timestamp in localStorage per event token after each page render, so the indicator clears on subsequent visits.
- **FR-003**: System MUST NOT show a "new update" indicator on a guest's first visit to an event page (when no `updates_last_seen_at` exists in localStorage for that event token).
- **FR-004**: System MUST initialize `updates_last_seen_at` in localStorage on first visit, set to the timestamp of the most recent update (or a sentinel value if no updates exist), to prevent spurious indicators on subsequent visits.
- **FR-005**: System MUST NOT transmit any data to the server when a guest views or is marked as having read update messages — read tracking is purely client-side.
- **FR-006**: System MUST use a localStorage key distinct from the `last_seen_at` key used in US-9 to avoid conflicts between the two read-state mechanisms.
- **FR-007**: System MUST degrade gracefully if localStorage is unavailable: no indicator is shown, and no error is surfaced to the user.
### Key Entities
- **UpdateReadState** (client-side only): Stored in localStorage, keyed by event token. Contains `updates_last_seen_at` (timestamp of the most recent update at last visit). Never transmitted to the server.
## Success Criteria
### Measurable Outcomes
- **SC-001**: A guest who has not visited an event page since a new update was posted sees a visual indicator on their next visit, without any server request being made to track readership.
- **SC-002**: After the event page is rendered, the same guest sees no indicator on their next visit (indicator clears after viewing).
- **SC-003**: A first-time visitor to an event page with existing updates sees no "new" indicator.
- **SC-004**: No network request is sent to the server when the read state transitions from unread to read.
- **SC-005**: The read-state mechanism is independent of US-9's field-change highlight mechanism — toggling one does not affect the other.

60
specs/017-qr-code/spec.md Normal file
View File

@@ -0,0 +1,60 @@
# Feature Specification: Generate a QR Code for an Event
**Feature**: `017-qr-code`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Display and Download QR Code (Priority: P1)
Any visitor who holds the event link can view a QR code on the event page that encodes the public event URL. The QR code is generated server-side — no external service is contacted — and can be downloaded as a print-ready file (SVG or high-resolution PNG). This makes it easy to print the code on posters or flyers.
**Why this priority**: This is the core deliverable of US-11. Without a downloadable, server-generated QR code, the feature has no value. All other criteria are conditions of this baseline.
**Independent Test**: Can be fully tested by loading any event page and verifying that a QR code is displayed, that a download link produces a valid SVG or PNG file whose content encodes the correct event URL, and that no external network request was made to generate it.
**Acceptance Scenarios**:
1. **Given** a valid event exists, **When** a visitor opens the event page, **Then** a QR code encoding the public event URL is displayed on the page.
2. **Given** a QR code is displayed, **When** the visitor clicks the download link, **Then** a file (SVG or high-resolution PNG) is downloaded directly from the app's backend without client-side generation.
3. **Given** the downloaded file, **When** it is scanned with a QR reader, **Then** it resolves to the correct public event URL.
4. **Given** the QR code endpoint is accessed, **When** the server generates the code, **Then** no request is made to any external QR code service.
5. **Given** a visitor who has not RSVPed or logged in, **When** they access the event page, **Then** the QR code and download link are still available — no organizer token or RSVP required.
6. **Given** the event has expired, **When** a visitor opens the event page, **Then** the QR code and download link remain available and functional.
7. **Given** the QR code download is requested, **When** the server handles the request, **Then** no personal data, IP address, or identifier is transmitted to any third party.
---
### Edge Cases
- What happens when the event does not exist? The server returns "event not found" — the QR code endpoint must behave consistently and not leak data.
- How does the download link behave when the event URL is long? The QR code must be generated at sufficient error-correction level to remain scannable even for longer URLs.
## Requirements
### Functional Requirements
- **FR-001**: The event page MUST display a QR code that encodes the public event URL.
- **FR-002**: The QR code MUST be generated entirely server-side — no external QR code service or third-party API may be contacted.
- **FR-003**: The QR code MUST be downloadable as a file suitable for printing (SVG or high-resolution PNG).
- **FR-004**: The QR code download MUST be served from a direct backend endpoint — the actual file download MUST NOT require client-side generation.
- **FR-005**: The QR code MUST be accessible to any visitor holding the event link; no organizer token or RSVP is required.
- **FR-006**: No personal data, IP address, or identifier MUST be transmitted to any third party when the QR code is generated or downloaded.
- **FR-007**: The QR code MUST remain available and downloadable after the event has expired.
- **FR-008**: The QR code endpoint MUST return a consistent "event not found" response if the event does not exist — no partial data or error traces may be exposed.
### Key Entities
- **QRCode**: Virtual — no independent storage. Generated on demand from the event token and the public event URL. Not persisted.
## Success Criteria
### Measurable Outcomes
- **SC-001**: A visitor can view a QR code on any event page without performing any additional action (no login, no RSVP).
- **SC-002**: The downloaded file scans correctly to the event URL in at least two independent QR reader applications.
- **SC-003**: No outbound network request to an external service is made during QR code generation (verifiable via network inspection).
- **SC-004**: The QR code endpoint returns a valid file for both active and expired events.
- **SC-005**: The download link works without JavaScript (direct server endpoint).

View File

@@ -0,0 +1,90 @@
# Feature Specification: Automatic Data Deletion After Expiry Date
**Feature**: `018-data-deletion`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Automatic cleanup of expired event data (Priority: P1)
As a guest, I want all event data — including my RSVP and any other stored personal information — to be automatically and permanently deleted after the event's expiry date, so that I can trust that data I submitted is not retained on the server longer than necessary.
**Why this priority**: This is a privacy guarantee, not merely a housekeeping task. The mandatory expiry date in US-1 is only meaningful if the server actually enforces deletion. Without this story, the expiry date is a lie.
**Independent Test**: Can be tested by creating an event with a near-future expiry date, submitting an RSVP, waiting for expiry, and verifying that the event's public URL returns "event not found" and no data remains accessible.
**Acceptance Scenarios**:
1. **Given** an event whose expiry date has passed, **When** the cleanup process runs, **Then** the event record and all associated data (RSVPs, update messages, field-change metadata, header images, cancellation state) are permanently deleted from the server.
2. **Given** an event that has been deleted by the cleanup process, **When** a guest navigates to the event's public URL, **Then** the server returns a clear "event not found" response with no partial data or error traces.
3. **Given** a deletion event, **When** the cleanup process deletes data, **Then** no log entry records the names, RSVPs, or any personal data of the deleted event's guests — the deletion is silent from a logging perspective.
4. **Given** the cleanup process, **When** it runs, **Then** it runs automatically without manual operator intervention (e.g. via a scheduled job or on-request lazy cleanup triggered by access attempts).
---
### User Story 2 - Expiry date extension delays deletion (Priority: P2)
As an event organizer, I want to be able to extend the expiry date of my event (via US-5) and have the deletion be deferred accordingly, so that I can keep my event data available for longer when needed.
**Why this priority**: Ensures US-5 (edit expiry date) and US-12 (deletion) work together correctly — the cleanup must always use the current stored expiry date, not the original one.
**Independent Test**: Can be tested by creating an event, extending its expiry date before expiry passes, and verifying that the event data is not deleted until the new expiry date.
**Acceptance Scenarios**:
1. **Given** an event whose expiry date was extended via US-5 before the original expiry passed, **When** the cleanup process runs after the original date but before the new date, **Then** the event data is not deleted.
2. **Given** an event whose expiry date was extended, **When** the new expiry date passes and the cleanup process runs, **Then** the event and all associated data are deleted.
---
### User Story 3 - Cleanup does not trigger early (Priority: P2)
As a guest, I want my event data to be retained until the expiry date has fully passed, so that I can access the event page right up until expiry without unexpected data loss.
**Why this priority**: Ensures correctness — data must not be deleted prematurely.
**Independent Test**: Can be tested by verifying that an event with an expiry date set to tomorrow remains fully accessible today.
**Acceptance Scenarios**:
1. **Given** an event whose expiry date is today but has not yet passed, **When** a guest accesses the event page, **Then** the event data is still served normally.
2. **Given** an event whose expiry date passed yesterday, **When** the cleanup process runs, **Then** the event and all associated data are deleted.
---
### Edge Cases
- What happens if the cleanup process fails mid-run (e.g. server crash)? The next run must safely re-attempt deletion without corrupting partial state.
- What happens if multiple cleanup runs overlap? The process must be idempotent — deleting an already-deleted event must not cause errors.
- LocalStorage entries on guests' devices are unaffected by server-side deletion — this is intentional. The app handles the "event not found" response gracefully (US-2, US-7).
- If a stored header image file is missing on disk at deletion time (e.g. corrupted storage), the cleanup must still complete and delete the database record.
## Requirements
### Functional Requirements
- **FR-001**: The server MUST run a periodic cleanup process that automatically deletes all data associated with events whose expiry date has passed.
- **FR-002**: The cleanup MUST delete the event record along with all associated data: RSVPs, update messages (US-10a), field-change metadata (US-9), stored header images (US-16) [deferred until US-16 is implemented], and cancellation state (US-18 if applicable).
- **FR-003**: After deletion, the event's public URL MUST return a clear "event not found" response — no partial data is ever served.
- **FR-004**: The cleanup process MUST run automatically without manual operator intervention (e.g. a scheduled job, Spring `@Scheduled`, or on-request lazy cleanup triggered by access attempts).
- **FR-005**: The cleanup MUST NOT log the names, RSVPs, or any personal data of deleted events — deletion is silent from a logging perspective.
- **FR-006**: The cleanup MUST always use the current stored expiry date when determining whether an event is eligible for deletion — extending the expiry date via US-5 before expiry passes delays deletion accordingly.
- **FR-007**: The cleanup MUST NOT be triggered early — data is retained until the expiry date has passed, not before.
- **FR-008**: The cleanup process MUST be idempotent — re-running it against already-deleted events must not cause errors.
### Key Entities
- **Event (expiry_date)**: The `expiry_date` field on the Event entity determines when the event becomes eligible for deletion. It is updated by US-5 (edit event details).
- **Cleanup Job**: A background process (not a user-facing entity) responsible for identifying and deleting expired events and all their associated data.
## Success Criteria
### Measurable Outcomes
- **SC-001**: An event with a passed expiry date returns "event not found" from the server within one cleanup cycle of the expiry.
- **SC-002**: All associated data (RSVPs, update messages, metadata, images) is deleted atomically with the event record — no orphaned records remain after a successful cleanup run.
- **SC-003**: No PII (names, RSVP choices) appears in server logs during or after the deletion process.
- **SC-004**: Extending an event's expiry date via US-5 correctly defers deletion — verified by querying the database after the original expiry would have triggered cleanup.
- **SC-005**: The cleanup process completes successfully even if a stored header image file is missing on disk (resilient to partial storage failures).

View File

@@ -0,0 +1,88 @@
# Feature Specification: Limit Active Events Per Instance
**Feature**: `019-instance-limit`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md (US-13)
## User Scenarios & Testing
### User Story 1 - Enforce Configured Event Cap on Creation (Priority: P1)
As a self-hoster, I want to configure a maximum number of simultaneously active events via a server environment variable, so that I can prevent storage exhaustion and limit potential abuse on my instance without modifying code.
**Why this priority**: The event cap is the primary deliverable of this story — without it, there is no feature. All other scenarios are edge cases of this core enforcement behavior.
**Independent Test**: Can be fully tested by configuring `MAX_ACTIVE_EVENTS=1`, creating one event, then attempting to create a second — the second creation should be rejected with a clear error.
**Acceptance Scenarios**:
1. **Given** the server is configured with `MAX_ACTIVE_EVENTS=3` and 3 non-expired events exist, **When** a user submits the event creation form, **Then** the server rejects the request with a clear error indicating the instance is at capacity, and the frontend surfaces this error on the creation form — not as a silent failure.
2. **Given** the server is configured with `MAX_ACTIVE_EVENTS=3` and 2 non-expired events exist, **When** a user submits the event creation form, **Then** the request succeeds and the new event is created normally.
3. **Given** the server is configured with `MAX_ACTIVE_EVENTS=3` and 3 non-expired events exist, but 1 is past its expiry date (awaiting cleanup), **When** a user submits the event creation form, **Then** the request succeeds — expired events do not count toward the limit.
---
### User Story 2 - No Limit When Variable Is Unset (Priority: P2)
As a self-hoster running a personal or trusted-group instance, I want no event limit applied by default, so that I do not need to configure anything to run the app normally.
**Why this priority**: The default behavior (unlimited) must be safe and require no configuration. Self-hosters who do not need a cap should not have to think about this setting.
**Independent Test**: Can be fully tested by starting the server without `MAX_ACTIVE_EVENTS` set and verifying that multiple events can be created without rejection.
**Acceptance Scenarios**:
1. **Given** the server has no `MAX_ACTIVE_EVENTS` environment variable set, **When** any number of events are created, **Then** no capacity error is returned — event creation is unlimited.
2. **Given** the server has `MAX_ACTIVE_EVENTS` set to an empty string, **When** events are created, **Then** no capacity error is returned — an empty value is treated the same as unset.
---
### User Story 3 - Cap Is Enforced Server-Side Only (Priority: P2)
As a self-hoster, I want the event cap to be enforced exclusively on the server, so that it cannot be bypassed by a modified or malicious client.
**Why this priority**: Client-side enforcement alone would be trivially bypassable. The server is the authoritative enforcement point.
**Independent Test**: Can be fully tested by sending a direct HTTP POST to the event creation endpoint (bypassing the frontend entirely) when the cap is reached — the server must reject it.
**Acceptance Scenarios**:
1. **Given** the configured cap is reached, **When** a direct HTTP POST is made to the event creation endpoint (bypassing the frontend), **Then** the server returns an error response indicating the instance is at capacity.
2. **Given** the configured cap is reached, **When** no personal data is included in the rejection response or logs, **Then** the server returns only the rejection status — no PII is logged.
---
### Edge Cases
- What happens when `MAX_ACTIVE_EVENTS=0`? [NEEDS EXPANSION — treat as "no limit" or "reject all"? Clarify during implementation.]
- What happens when `MAX_ACTIVE_EVENTS` is set to a non-integer value? The server should fail fast at startup with a clear configuration error.
- Race condition: two concurrent creation requests when the cap is at N-1. The server must handle this atomically — one request succeeds, the other is rejected.
- Expired events that have not yet been cleaned up must not count toward the limit. The check must query only non-expired events.
## Requirements
### Functional Requirements
- **FR-001**: The server MUST read a `MAX_ACTIVE_EVENTS` environment variable at startup to determine the event creation cap.
- **FR-002**: If `MAX_ACTIVE_EVENTS` is set to a positive integer and the number of non-expired events equals or exceeds that value, the server MUST reject new event creation requests with a clear error response.
- **FR-003**: The frontend MUST surface the capacity error on the event creation form — not as a silent failure or generic error.
- **FR-004**: If `MAX_ACTIVE_EVENTS` is unset or empty, the server MUST apply no limit — event creation is unlimited.
- **FR-005**: Only non-expired events MUST count toward the limit; expired events awaiting cleanup are excluded from the count.
- **FR-006**: The limit MUST be enforced server-side; client-side state or input cannot bypass it.
- **FR-007**: No personal data or PII MUST be logged when a creation request is rejected due to the cap.
- **FR-008**: The `MAX_ACTIVE_EVENTS` environment variable MUST be documented in the README's self-hosting section (configuration table).
### Key Entities
- **Event (active count)**: The count of events whose `expiry_date` is in the future. This is the value checked against `MAX_ACTIVE_EVENTS` at event creation time.
## Success Criteria
### Measurable Outcomes
- **SC-001**: When the cap is reached, a POST to the event creation endpoint returns an appropriate HTTP error status with a machine-readable error body.
- **SC-002**: The capacity error is displayed to the user on the creation form with a message that does not expose internal state or configuration values.
- **SC-003**: Creating events up to but not exceeding the cap succeeds without any change in behavior compared to uncapped instances.
- **SC-004**: The `MAX_ACTIVE_EVENTS` variable appears in the README configuration table with its type, default, and description documented.
- **SC-005**: Expired events (past `expiry_date`) are never counted toward the cap, verifiable by inspecting the query or checking behavior after expiry.

73
specs/020-pwa/spec.md Normal file
View File

@@ -0,0 +1,73 @@
# Feature Specification: Install as Progressive Web App
**Feature**: `020-pwa`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Install the app on a mobile or desktop device (Priority: P1)
**As a** guest,
**I want to** install the app on my device from the browser,
**so that** it feels like a native app and I can launch it directly from my home screen.
**Acceptance Scenarios**:
1. **Given** the app is open in a supported mobile browser, **When** the user opens the browser menu, **Then** an "Add to Home Screen" or install prompt is available because the app serves a valid manifest and a registered service worker.
2. **Given** the user has installed the app, **When** they launch it from the home screen, **Then** the app opens in standalone mode — without browser address bar or navigation chrome.
3. **Given** the app is installed, **When** the user views their device's home screen or app switcher, **Then** the configured icon and app name are displayed.
4. **Given** the user has visited the app previously, **When** they open the app again (including on a slow or offline connection), **Then** previously loaded pages and assets load quickly due to service worker caching.
5. **Given** the app is installed and launched from the home screen, **When** the app starts, **Then** the start URL is the root page (`/`), which serves the local event overview (US-7), so returning users see their tracked events immediately.
### User Story 2 - Serve a valid web app manifest (Priority: P1)
**As a** browser,
**I want** the app to serve a well-formed web app manifest,
**so that** I can offer the user an install prompt and render the installed app correctly.
**Acceptance Scenarios**:
1. **Given** a browser fetches the app's manifest, **Then** it includes at minimum: app name, icons in multiple sizes, standalone display mode, theme color, and a start URL.
2. **Given** the manifest and service worker are present, **Then** the app meets browser installability requirements (manifest + registered service worker) so that the install prompt is available on supported browsers.
3. **Given** the manifest or service worker loads assets, **Then** no external resources are fetched — all assets referenced are self-hosted.
### Edge Cases
- Browsers that do not support service workers or PWA installation should still render the app normally — PWA features are an enhancement, not a requirement.
- If the service worker fails to install (e.g. due to a browser policy), the app must still function as a standard web page.
- Service worker caching strategy (cache-first, network-first, stale-while-revalidate) is an implementation decision to be determined during implementation.
## Requirements
### Functional Requirements
- **FR-01**: The app serves a valid web app manifest (JSON) accessible at a standard path (e.g. `/manifest.webmanifest` or `/manifest.json`).
- **FR-02**: The manifest includes at minimum: app name, short name, icons in multiple sizes (e.g. 192x192 and 512x512), `display: "standalone"`, `theme_color`, `background_color`, and `start_url: "/"`.
- **FR-03**: The app registers a service worker that meets browser installability requirements alongside the manifest.
- **FR-04**: When launched from the home screen, the app opens in standalone mode without browser navigation chrome.
- **FR-05**: The app icon and name are shown on the device home screen and in the OS app switcher after installation.
- **FR-06**: The service worker caches app assets so that repeat visits load quickly.
- **FR-07**: The manifest's start URL is `/`, serving the local event overview (US-7).
- **FR-08**: No external resources (CDNs, external fonts, remote icons) are fetched by the manifest or service worker — all assets are self-hosted.
- **FR-09**: PWA installability does not depend on any backend state — it is a purely frontend concern served by the static assets.
### Key Entities
- **Web App Manifest**: A static JSON file describing the app's identity, icons, display mode, and start URL. No database storage required.
- **Service Worker**: A client-side script registered by the app to handle caching and (optionally) offline behavior. No server-side representation.
## Success Criteria
1. A supported mobile browser shows an "Add to Home Screen" prompt or install banner for the app.
2. After installation, the app launches in standalone mode with the correct icon and name.
3. The installed app's start URL leads directly to the local event overview (`/`).
4. Repeat visits are noticeably faster due to service worker asset caching.
5. No external resources are loaded by the manifest or service worker — all assets are served from the app's own origin.

View File

@@ -0,0 +1,74 @@
# Feature Specification: Choose Event Color Theme
**Feature**: `021-color-themes`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Select color theme during event creation (Priority: P1)
An organizer creating a new event can select a visual color theme from a predefined set. If no theme is selected, a default theme is applied automatically. The choice is persisted server-side and the guest-facing event page renders with the selected theme.
**Why this priority**: Core feature value — without theme selection during creation, the feature has no entry point.
**Independent Test**: Can be fully tested by creating an event, selecting a non-default theme, and verifying the event page renders with the correct theme applied.
**Acceptance Scenarios**:
1. **Given** the event creation form is open, **When** the organizer selects a predefined color theme and submits the form, **Then** the theme is persisted server-side alongside the event data.
2. **Given** an event has been created with a specific theme, **When** a guest opens the event page, **Then** the page renders with the selected color theme applied.
3. **Given** the event creation form is open, **When** the organizer submits the form without selecting a theme, **Then** a default theme is applied and persisted server-side.
4. **Given** a theme has been applied to an event, **When** the event page renders, **Then** only the event page is themed — the app's global UI (navigation, local overview, forms) is unaffected.
5. **Given** a predefined theme is applied, **When** the event page renders, **Then** no external resources are required — all styles are self-contained.
---
### User Story 2 - Update color theme via event editing (Priority: P2)
An organizer editing an existing event can change the event's color theme. The updated theme is persisted server-side and reflected immediately on the guest-facing event page.
**Why this priority**: Extends the P1 creation flow to editing. Less critical than initial theme selection but necessary for full feature completeness.
**Independent Test**: Can be fully tested by editing an existing event, changing its theme, and verifying the event page updates accordingly.
**Acceptance Scenarios**:
1. **Given** an event with an existing theme, **When** the organizer opens the edit form (US-5), **Then** the current theme selection is shown in the customization UI.
2. **Given** the organizer changes the theme in the edit form and saves, **When** a guest opens the event page, **Then** the updated theme is applied.
---
### Edge Cases
- What happens when the theme value stored server-side no longer matches any predefined theme (e.g. after an app upgrade removes a theme)? The system must fall back to the default theme gracefully — no broken styles.
- How does the event-level color theme interact with the app-level dark/light mode (US-17)? Predefined themes must remain readable and visually coherent regardless of whether dark or light mode is active in the surrounding app chrome.
- What if no theme is stored at all (legacy events created before this feature)? The default theme must be applied as a safe fallback.
## Requirements
### Functional Requirements
- **FR-001**: The system MUST offer a set of predefined color themes for event pages.
- **FR-002**: The event creation form MUST include a theme selection UI (e.g. color swatches or named options).
- **FR-003**: The event editing form (US-5) MUST include the same theme selection UI, pre-populated with the current theme.
- **FR-004**: The selected theme MUST be persisted server-side as part of the event record.
- **FR-005**: A default theme MUST be applied if the organizer makes no explicit selection.
- **FR-006**: The guest-facing event page MUST render with the event's stored color theme.
- **FR-007**: Event-level color themes MUST affect only the event page — not the app's global UI (navigation, local overview, forms, or any other chrome).
- **FR-008**: All theme styles MUST be self-contained — no external resources (CDNs, external stylesheets) may be required for any predefined theme.
### Key Entities
- **ColorTheme**: A predefined named color scheme. Stored as a reference (e.g. a string identifier) on the Event entity. Not an independent database entity — it is a value type on the Event.
## Success Criteria
### Measurable Outcomes
- **SC-001**: An organizer can select a color theme during event creation and the selection is reflected on the event page without additional steps.
- **SC-002**: All predefined themes render correctly without external network requests.
- **SC-003**: Changing the theme via the edit form (US-5) is reflected immediately on the next event page load.
- **SC-004**: The default theme is applied automatically to all events without an explicit theme selection, including legacy events created before this feature.
- **SC-005**: Event-level theming does not interfere with the app-level dark/light mode (US-17) — both can coexist without breaking contrast or readability (WCAG AA).

View File

@@ -0,0 +1,106 @@
# Feature Specification: Select Event Header Image from Unsplash
**Feature**: `022-header-image`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Select header image during creation or editing (Priority: P1)
The event organizer can search for a header image via an integrated Unsplash search during event creation or editing. The search is server-proxied — the client never contacts Unsplash directly. When an image is selected, the server downloads and stores it locally. Proper Unsplash attribution is displayed on the event page.
**Why this priority**: Core functionality of this feature. All other stories depend on an image being selectable and stored.
**Independent Test**: Can be fully tested by creating an event with a header image selected, verifying the event page renders the image served from the app's own domain with attribution.
**Acceptance Scenarios**:
1. **Given** the organizer is on the event creation or editing form and the server has an Unsplash API key configured, **When** the organizer enters a search query, **Then** the client sends the query to the app's backend which proxies it to Unsplash and returns results — the client never contacts Unsplash directly.
2. **Given** the organizer selects an image from search results, **When** the selection is confirmed, **Then** the server downloads and stores the image locally on disk and serves it from the app's own domain.
3. **Given** an event has a stored header image, **When** a guest views the event page, **Then** the header image is rendered and Unsplash attribution (photographer name and link to Unsplash) is displayed alongside it.
4. **Given** an event has a header image, **When** the organizer removes it, **Then** the image is no longer displayed on the event page.
---
### User Story 2 - Event page renders header image (Priority: P1)
The guest-facing event page renders the selected header image if one is set. The image is served from the app's own domain, not from Unsplash's CDN, so no guest data or IP address is transmitted to Unsplash.
**Why this priority**: Directly impacts the visual presentation that motivated the feature.
**Independent Test**: Can be tested by loading an event page with a stored header image and verifying the image URL is on the app's domain and no network request is made to Unsplash domains.
**Acceptance Scenarios**:
1. **Given** an event has a stored header image, **When** a guest opens the event page, **Then** the image is loaded from the app's own domain and no request is made to Unsplash or any third-party CDN.
2. **Given** an event has no header image set, **When** a guest opens the event page, **Then** the page renders without a header image and no error is shown.
---
### User Story 3 - Feature unavailable when API key not configured (Priority: P2)
If the server has no Unsplash API key configured, the image search feature is simply not shown in the UI. No error is displayed. Existing stored images continue to serve normally if the key is removed after images were already stored.
**Why this priority**: Required for graceful degradation — the feature must not break the app when the API key is absent.
**Independent Test**: Can be tested by starting the server without `UNSPLASH_API_KEY` set and verifying the image search UI is absent from the creation and editing forms, and that any previously stored images still render.
**Acceptance Scenarios**:
1. **Given** the server has no `UNSPLASH_API_KEY` environment variable set, **When** the organizer opens the event creation or editing form, **Then** the image search option is not shown — no error, no broken UI element.
2. **Given** the API key is removed from the config after images were already stored, **When** a guest opens an event page with a previously stored image, **Then** the image still renders from disk and only the search/select UI becomes unavailable.
---
### User Story 4 - Image deleted with event on expiry (Priority: P2)
When an event expires and is deleted by the cleanup process (US-12), the stored header image file is deleted along with all other event data.
**Why this priority**: Privacy requirement — stored files must not outlive the event data.
**Independent Test**: Can be tested by creating an event with a header image, expiring it, running the cleanup process, and verifying the image file no longer exists on disk.
**Acceptance Scenarios**:
1. **Given** an event with a stored header image has passed its expiry date, **When** the cleanup process runs (US-12), **Then** the image file is deleted from disk along with the event record and all associated data.
2. **Given** an event with a header image is explicitly deleted by the organizer (US-19), **When** the deletion is confirmed, **Then** the image file is deleted from disk immediately.
---
### Edge Cases
- What happens when the Unsplash API returns an error or rate-limit response? — Server returns a clear error to the client; the organizer can retry.
- What happens when disk is full and the server cannot store the downloaded image? — Server returns an error; the event can still be created/saved without an image.
- What happens if an image download from Unsplash fails mid-transfer? — Server returns an error; no partial file is stored.
- What happens if the `UNSPLASH_API_KEY` is set but invalid? — Server proxies the call and returns the Unsplash API error to the client (e.g. "unauthorized").
## Requirements
### Functional Requirements
- **FR-001**: The server MUST expose an Unsplash image search proxy endpoint that accepts a query string, calls the Unsplash API server-side, and returns results to the client — the client MUST NOT contact Unsplash directly.
- **FR-002**: When an image is selected, the server MUST download and store the image file locally on disk; the image MUST be served from the app's own domain.
- **FR-003**: The server MUST store Unsplash attribution metadata (photographer name and Unsplash URL) alongside the image reference and display it on the event page.
- **FR-004**: The organizer MUST be able to remove a previously selected header image.
- **FR-005**: The guest-facing event page MUST render the header image if one is set, served from the app's own domain.
- **FR-006**: If `UNSPLASH_API_KEY` is not configured, the image search UI MUST NOT be shown; no error is displayed.
- **FR-007**: If the API key is removed after images were stored, existing images MUST continue to render from disk; only the search/select UI becomes unavailable.
- **FR-008**: The server MUST NOT log any guest IP address or identifier when serving the stored image.
- **FR-009**: When an event is deleted (US-12 or US-19), the server MUST delete the stored image file along with all other event data.
- **FR-010**: The `UNSPLASH_API_KEY` environment variable and the persistent volume requirement for image storage MUST be documented in the README's self-hosting section.
### Key Entities
- **HeaderImage**: Stored image file on disk, associated with an event. Attributes: local file path, Unsplash image ID, photographer name, photographer URL, Unsplash attribution URL. Not independently stored — deleted with the event.
## Success Criteria
### Measurable Outcomes
- **SC-001**: A guest opening an event page with a header image makes no network requests to Unsplash or any third-party domain.
- **SC-002**: Removing the `UNSPLASH_API_KEY` does not break the app, the event creation form, or the rendering of previously stored images.
- **SC-003**: After event expiry and cleanup, no image file remains on disk for that event.
- **SC-004**: Unsplash attribution (photographer name and link) is visible on every event page that has a header image.
- **SC-005**: The image search, download, and storage flow works end-to-end when a valid API key is configured.

120
specs/023-dark-mode/spec.md Normal file
View File

@@ -0,0 +1,120 @@
# Feature Specification: Dark/Light Mode
**Feature**: `023-dark-mode`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - System preference respected on first visit (Priority: P1)
A user opens the app for the first time. The app automatically adopts their operating system or browser dark/light preference without any manual configuration required. No preference data is transmitted to the server.
**Why this priority**: This is the baseline behavior — it works without any user interaction and provides the correct experience immediately.
**Independent Test**: Can be tested by opening the app in a browser with `prefers-color-scheme: dark` set at the OS level and verifying the dark theme is applied, then repeating with light preference.
**Acceptance Scenarios**:
1. **Given** a user opens the app for the first time with no manual preference stored, **When** the OS/browser preference is `prefers-color-scheme: dark`, **Then** the app renders in dark mode.
2. **Given** a user opens the app for the first time with no manual preference stored, **When** the OS/browser preference is `prefers-color-scheme: light`, **Then** the app renders in light mode.
3. **Given** the app is rendering in either mode, **When** the page is loaded, **Then** no server request is made and no preference data is transmitted.
---
### User Story 2 - Manual toggle overrides system preference (Priority: P1)
A user can switch between dark and light mode using a visible toggle available on any page. Their choice is persisted in localStorage and takes precedence over the OS preference on all subsequent visits.
**Why this priority**: The explicit user preference must be honoured and must persist — without this, the toggle would reset on every visit, making it unusable.
**Independent Test**: Can be tested by toggling the mode, closing and reopening the browser, and verifying the manually selected mode is still active even if it differs from the OS preference.
**Acceptance Scenarios**:
1. **Given** the app is in light mode (system preference), **When** the user activates the dark mode toggle, **Then** the UI immediately switches to dark mode and the preference is stored in localStorage.
2. **Given** the user has a dark mode preference stored in localStorage, **When** the user revisits the app, **Then** dark mode is applied regardless of the current OS/browser preference.
3. **Given** the user has a light mode preference stored in localStorage and the OS preference is dark, **When** the user revisits the app, **Then** light mode is applied (localStorage takes precedence).
4. **Given** the app is running, **When** the user toggles the mode, **Then** no server request is made and no preference data is transmitted.
---
### User Story 3 - Toggle accessible from any page (Priority: P2)
The dark/light mode toggle is reachable from every page of the app — event pages, local event overview, creation form, etc. — so the user never has to navigate away to change their preference.
**Why this priority**: This is a usability enhancement. The feature works without it (if the toggle were only on one page), but accessibility from any page is important for a good experience.
**Independent Test**: Can be tested by navigating to the event page, the local event overview, and the creation form and verifying the toggle is visible and functional on each.
**Acceptance Scenarios**:
1. **Given** the user is on the local event overview page (`/`), **When** they look for the mode toggle, **Then** it is visible and functional.
2. **Given** the user is on an event page, **When** they look for the mode toggle, **Then** it is visible and functional.
3. **Given** the user is on the event creation form, **When** they look for the mode toggle, **Then** it is visible and functional.
---
### User Story 4 - Dark/light mode does not affect event-level color themes (Priority: P2)
Dark/light mode affects only the app's global UI chrome (navigation, local event overview, forms, etc.). Individual event pages use their own color theme (US-15), which is independent of the app-level dark/light setting.
**Why this priority**: Necessary to define the boundary between app-level theming and event-level theming clearly, but secondary to the core toggle behaviour.
**Independent Test**: Can be tested by creating an event with a custom color theme, then toggling dark/light mode and verifying the event page theme is unaffected while the surrounding chrome does change.
**Acceptance Scenarios**:
1. **Given** an event page is rendered with a custom color theme (US-15), **When** the user switches the app to dark mode, **Then** the event page color theme remains unchanged (only surrounding chrome changes).
2. **Given** the app is in dark mode, **When** the user navigates to the local event overview, **Then** the overview uses the dark color scheme.
---
### User Story 5 - Both modes meet WCAG AA contrast (Priority: P1)
Both dark and light modes must meet accessibility contrast requirements at the WCAG AA minimum level, ensuring the app is usable for users with visual impairments in both modes.
**Why this priority**: Accessibility is a baseline requirement per the project statutes, not an afterthought.
**Independent Test**: Can be tested using automated contrast checking tools against both mode variants.
**Acceptance Scenarios**:
1. **Given** the app is in dark mode, **When** text and interactive elements are checked for contrast ratio, **Then** all text/background pairings meet WCAG AA minimum (4.5:1 for normal text, 3:1 for large text).
2. **Given** the app is in light mode, **When** text and interactive elements are checked for contrast ratio, **Then** all text/background pairings meet WCAG AA minimum.
---
### Edge Cases
- What happens when the OS `prefers-color-scheme` value changes while the app is open (e.g. user switches OS theme at runtime)? If no manual preference is stored in localStorage, should the app react? [NEEDS EXPANSION during implementation]
- What happens when localStorage is unavailable (private browsing with strict settings)? The system preference fallback must still work without crashing.
- How does app-level dark/light mode interact with the event-level color themes (US-15) when an event page is embedded in the app chrome? Themes should remain readable in both modes.
## Requirements
### Functional Requirements
- **FR-001**: The app MUST detect and apply `prefers-color-scheme` as the default on first visit when no manual preference is stored in localStorage.
- **FR-002**: The app MUST provide a visible toggle UI element to switch between dark and light mode, accessible from any page.
- **FR-003**: The app MUST persist the user's manual mode preference in localStorage and apply it on subsequent visits, overriding the system preference.
- **FR-004**: Dark/light mode MUST affect all global app chrome: navigation, local event overview, event creation/editing forms, and all non-event-page UI elements.
- **FR-005**: Dark/light mode MUST NOT affect individual event page color themes (US-15); event pages are styled independently.
- **FR-006**: The mode switch MUST be entirely client-side; no server request is made and no preference data is transmitted.
- **FR-007**: Both dark and light modes MUST meet WCAG AA contrast requirements for all text and interactive elements.
- **FR-008**: The toggle MUST be accessible (keyboard-navigable, labelled for screen readers).
### Key Entities
- **DarkLightPreference**: A client-side-only value (`"dark"` | `"light"` | absent) stored in localStorage. No server-side equivalent. Determines which CSS theme is applied to the global app chrome.
## Success Criteria
### Measurable Outcomes
- **SC-001**: On first visit with `prefers-color-scheme: dark`, the dark theme is applied without any user interaction.
- **SC-002**: A user's manual toggle selection persists across browser sessions and overrides the OS preference.
- **SC-003**: The mode toggle is visible and functional on all primary app pages (local event overview, event page, creation form).
- **SC-004**: Automated contrast checks pass WCAG AA thresholds for all text elements in both dark and light modes.
- **SC-005**: No network request is made when toggling the mode or when the stored preference is applied on page load.

View File

@@ -0,0 +1,96 @@
# Feature Specification: Cancel an Event as Organizer
**Feature**: `024-cancel-event`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Cancel event with optional message (Priority: P1)
The event organizer, from the organizer view, triggers a dedicated "Cancel event" action. A confirmation step is required before finalising. The organizer may optionally enter a cancellation message (reason or explanation). After confirmation, the server sets the event to cancelled state with the provided message. The event page immediately displays a "cancelled" state visible to all visitors. No RSVP submissions are accepted by the server from this point.
**Why this priority**: Cancellation is a fundamental lifecycle action — guests need to be clearly informed when an event is cancelled rather than discovering it silently. This is the core of US-18.
**Independent Test**: Can be fully tested by creating an event, triggering cancel (with and without a message), and verifying the event page shows a "cancelled" indicator and the RSVP form is absent.
**Acceptance Scenarios**:
1. **Given** an organizer with a valid organizer token in localStorage, **When** they click "Cancel event", **Then** a confirmation step is shown before any change is made.
2. **Given** the organizer confirms cancellation without entering a message, **When** the server processes the request, **Then** the event is marked cancelled and the event page shows a "cancelled" state with no message.
3. **Given** the organizer confirms cancellation with a message, **When** the server processes the request, **Then** the event page displays the "cancelled" state along with the cancellation message.
4. **Given** a cancelled event, **When** a guest attempts to submit an RSVP, **Then** the server rejects the submission and the RSVP form is not shown on the event page.
5. **Given** a cancelled event that has not yet expired, **When** any visitor opens the event URL, **Then** the full event page renders with a clear "cancelled" indicator — no partial data is hidden.
---
### User Story 2 - Adjust expiry date during cancellation (Priority: P2)
When cancelling, the organizer can optionally adjust the event's expiry date to control how long the cancellation notice remains visible before automatic data deletion (US-12). The adjusted date must be in the future, consistent with US-5's expiry date constraint.
**Why this priority**: The expiry date adjustment is a convenience — organizers may want to keep the cancellation notice visible for a specific period (e.g. one more week) or trigger earlier cleanup. The core cancellation (P1) works without it.
**Independent Test**: Can be tested by cancelling an event while setting a new expiry date in the future, then verifying the event's expiry date was updated and data persists until that date.
**Acceptance Scenarios**:
1. **Given** the organizer confirms cancellation with a new expiry date set in the future, **When** the server processes the request, **Then** the event is cancelled and its expiry date is updated to the provided value.
2. **Given** the organizer provides an expiry date in the past or set to today during cancellation, **When** the server processes the request, **Then** the request is rejected with a clear validation error.
3. **Given** the organizer confirms cancellation without adjusting the expiry date, **When** the server processes the request, **Then** the existing expiry date is unchanged.
---
### User Story 3 - Edit cancellation message after cancellation (Priority: P3)
After an event is cancelled, the organizer can update the cancellation message (e.g. to correct a typo or add further explanation). The cancelled state itself cannot be changed.
**Why this priority**: Editing the message is a refinement capability. The core behaviour (cancellation + message at time of cancellation) is sufficient for P1 and P2.
**Independent Test**: Can be tested by cancelling an event, then submitting an updated cancellation message via the organizer view, and verifying the new message is displayed on the event page.
**Acceptance Scenarios**:
1. **Given** a cancelled event with a valid organizer token, **When** the organizer submits an updated cancellation message, **Then** the event page displays the new message.
2. **Given** a cancelled event with a valid organizer token, **When** the organizer attempts to un-cancel (set the event back to active), **Then** the server rejects the request — cancellation is a one-way state transition.
3. **Given** a cancelled event with an absent or invalid organizer token, **When** a request is made to edit the cancellation message, **Then** the server rejects the request.
---
### Edge Cases
- Organizer token absent or invalid: the "Cancel event" action is not shown in the UI and the server rejects any cancel or message-edit request with an appropriate error response.
- RSVP on a cancelled event: the server rejects RSVP submissions with a clear error; the RSVP form is hidden on the client.
- Cancellation message is optional: omitting it is valid — the event still transitions to cancelled state with no message displayed.
- Cancellation + expiry: after the expiry date passes, the event is deleted by the cleanup process (US-12) regardless of cancelled state; the cancellation data is removed as part of the full event deletion.
- Already-expired event at time of cancellation: [NEEDS EXPANSION] — it is unclear whether cancellation should be allowed if the event has already expired but not yet been cleaned up. This edge case should be addressed during implementation.
## Requirements
### Functional Requirements
- **FR-001**: The Event entity MUST persist a `is_cancelled` boolean (default false) and an optional `cancellation_message` string, both server-side.
- **FR-002**: The cancel endpoint MUST require a valid organizer token; requests without a valid token are rejected.
- **FR-003**: The cancel endpoint MUST accept an optional plain-text cancellation message.
- **FR-004**: The cancel endpoint MUST accept an optional updated expiry date, which MUST be in the future; if provided and not in the future, the request is rejected with a clear validation error.
- **FR-005**: Cancellation MUST be a one-way state transition: once cancelled, the event cannot be set back to active via any API endpoint.
- **FR-006**: The event page MUST display a "cancelled" state indicator and the cancellation message (if present) for any visitor once the event is cancelled.
- **FR-007**: The RSVP endpoint MUST reject submissions for cancelled events; the RSVP form MUST be hidden on the client for cancelled events.
- **FR-008**: The organizer MUST be able to update the cancellation message after cancellation via a dedicated update action; this action MUST require a valid organizer token.
- **FR-009**: The UI MUST present a confirmation step before submitting the cancellation request; the cancel action MUST NOT be triggerable in a single click without confirmation.
- **FR-010**: The "Cancel event" action and organizer-specific cancel UI MUST NOT be rendered when no valid organizer token is present in localStorage.
- **FR-011**: No personal data, IP addresses, or identifiers MUST be logged during cancellation or cancellation message updates.
### Key Entities
- **CancellationState**: Value type on Event. Fields: `is_cancelled` (boolean, persistent), `cancellation_message` (optional string, persistent). Not a separate entity — stored on the Event record.
## Success Criteria
### Measurable Outcomes
- **SC-001**: An organizer with a valid token can cancel an event (with or without a message) in a single confirmed action, and the cancelled state is immediately reflected on the event page.
- **SC-002**: After cancellation, no guest can successfully submit an RSVP via the API, regardless of client-side state.
- **SC-003**: The event page correctly renders a "cancelled" indicator (and message if provided) for any visitor after cancellation — no RSVP form, no false "active" state.
- **SC-004**: Cancellation data (state and message) is removed automatically when the event expires and is deleted by the cleanup process (US-12).
- **SC-005**: No client or API call can revert a cancelled event to an active state.

View File

@@ -0,0 +1,90 @@
# Feature Specification: Delete an Event as Organizer
**Feature**: `025-delete-event`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - Immediately delete an active event (Priority: P1)
The organizer wants to permanently remove an event they created — because it was a mistake, contains wrong information, or is no longer needed at all. From the organizer view, they trigger a dedicated "Delete event" action. After confirming a clear warning, the server immediately and permanently deletes all event data. The organizer is redirected to the root page and the event's local entry is removed.
**Why this priority**: This is the core action of the story. Without it, organizers have no manual removal mechanism — they can only wait for automatic expiry (US-12). It is the "nuclear option" that complements editing (US-5) and cancellation (US-18).
**Independent Test**: Can be tested by creating an event, navigating to the organizer view, triggering deletion, confirming the warning, and verifying the event URL returns "not found" and the root page no longer lists the event.
**Acceptance Scenarios**:
1. **Given** a valid organizer token is present in localStorage for an event, **When** the organizer triggers "Delete event" and confirms the warning, **Then** the server permanently deletes the event record and all associated data (RSVPs, update messages, field-change metadata, stored header images, cancellation state), and the event's public URL returns an "event not found" response.
2. **Given** deletion succeeds, **When** the server responds with success, **Then** the app removes the event's organizer token and metadata from localStorage and redirects the organizer to the root page (`/`).
3. **Given** the organizer view is open, **When** the organizer triggers "Delete event", **Then** a confirmation warning is shown that clearly states the action is immediate, permanent, and irreversible — all event data including RSVPs, update messages, and images will be lost.
---
### User Story 2 - Delete a cancelled or expired event (Priority: P2)
The organizer wants to delete an event regardless of its current state — whether it is still active, already cancelled (US-18), or past its expiry date. Deletion must not be gated on event state.
**Why this priority**: Extending P1 to cover all event states. A cancelled event may have data the organizer wants removed immediately, without waiting for the expiry date.
**Independent Test**: Can be tested by cancelling an event (US-18), then triggering deletion from the organizer view and verifying that the deletion succeeds.
**Acceptance Scenarios**:
1. **Given** an event that has been cancelled (US-18), **When** the organizer confirms deletion, **Then** the server deletes the event and all its data, including the cancellation state, exactly as for an active event.
2. **Given** an event that has passed its expiry date but not yet been cleaned up by US-12, **When** the organizer confirms deletion, **Then** the deletion succeeds and the event is immediately removed.
---
### User Story 3 - Reject deletion without a valid organizer token (Priority: P2)
A visitor without a valid organizer token must not be able to delete an event — the delete action is not shown to them and the server rejects any deletion request.
**Why this priority**: Security boundary. Without this, any visitor who knows the event token could delete the event.
**Independent Test**: Can be tested by attempting a DELETE request to the event endpoint without a valid organizer token and verifying the server rejects it with an appropriate error.
**Acceptance Scenarios**:
1. **Given** no organizer token is present in localStorage for a given event, **When** a visitor opens the event page, **Then** the delete action is not shown anywhere in the UI.
2. **Given** an organizer token is absent or invalid, **When** a DELETE request is sent to the server for that event, **Then** the server rejects the request and does not delete any data.
---
### Edge Cases
- What happens if the organizer dismisses the confirmation warning? The deletion is aborted; no data is changed; the organizer remains on the event page.
- What happens if the network fails after confirmation but before the server responds? The event may or may not be deleted depending on whether the request reached the server. The app should display an error and not redirect; the organizer can retry.
- What happens if the organizer token in localStorage is valid but the event was already deleted (e.g. by US-12 cleanup between page load and deletion attempt)? The server returns "event not found"; the app should handle this gracefully and redirect to the root page, removing the stale localStorage entry.
- What if a stored header image file deletion fails during event deletion? The server must still delete the database record and must not leave the event in a partially-deleted state — image file cleanup failure should be logged server-side but must not abort deletion.
## Requirements
### Functional Requirements
- **FR-001**: The server MUST expose a DELETE endpoint for events, authenticated via the organizer token.
- **FR-002**: The server MUST permanently delete all associated data on deletion: RSVPs, update messages (US-10a), field-change metadata (US-9), stored header images (US-16), and cancellation state (US-18), in addition to the event record itself.
- **FR-003**: After deletion, the event's public URL MUST return an "event not found" response — no partial data may be served.
- **FR-004**: The frontend MUST show a confirmation warning before deletion, clearly stating that the action is immediate, permanent, and irreversible.
- **FR-005**: On successful deletion, the frontend MUST remove the event's organizer token and associated metadata from localStorage.
- **FR-006**: On successful deletion, the frontend MUST redirect the organizer to the root page (`/`).
- **FR-007**: The delete action MUST be accessible regardless of event state (active, cancelled, or expired).
- **FR-008**: If no valid organizer token is present, the delete action MUST NOT be shown in the UI and the server MUST reject the request.
- **FR-009**: No personal data or event data MUST be logged during deletion — deletion is silent from a logging perspective.
### Key Entities
- **Event**: The primary entity being deleted. Deletion removes it and all child data (RSVPs, update messages, field-change metadata, images, cancellation state).
- **OrganizerToken**: The authentication credential (UUID) stored in localStorage and validated server-side. Required to authorize deletion.
## Success Criteria
### Measurable Outcomes
- **SC-001**: After deletion, a GET request to the event's public URL returns "event not found" — verifiable in an automated test immediately after deletion.
- **SC-002**: After deletion, no record of the event, its RSVPs, or its messages exists in the database — verifiable via database assertion in integration tests.
- **SC-003**: After deletion, the localStorage entry for the event (organizer token + metadata) is removed — verifiable in an E2E test.
- **SC-004**: A DELETE request without a valid organizer token is rejected by the server — verifiable in an automated API test.
- **SC-005**: The confirmation warning is always shown before deletion is triggered — verifiable in an E2E test by asserting the dialog appears before any data is sent to the server.

View File

@@ -0,0 +1,48 @@
# Feature Specification: 404 Page
**Feature**: `026-404-page`
**Created**: 2026-03-06
**Status**: Draft
**Source**: Migrated from spec/userstories.md
## User Scenarios & Testing
### User Story 1 - See a helpful error page for unknown URLs (Priority: P1)
As a user who navigates to a non-existent URL, I want to see a helpful error page so that I can find my way back instead of seeing a blank screen.
**Why this priority**: Without this, users see a blank page on invalid routes. Basic UX hygiene.
**Independent Test**: Navigate to any non-existent path (e.g. `/does-not-exist`) and verify the error page renders with a way back.
**Acceptance Scenarios**:
1. **Given** a user navigates to a URL that does not match any defined route, **When** the page loads, **Then** a "Page not found" message is displayed.
2. **Given** the 404 page is displayed, **When** the user looks for navigation, **Then** a link back to the home page is visible and functional.
3. **Given** the 404 page is displayed, **When** its appearance is evaluated, **Then** it follows the project design system (Electric Dusk + Sora).
### Edge Cases
- What happens when the URL contains special characters or very long paths? The catch-all route should still match and display the 404 page.
- What happens when JavaScript is disabled? [NEEDS EXPANSION — SPA limitation]
## Requirements
### Functional Requirements
- **FR-001**: The Vue Router MUST include a catch-all route that matches any undefined path.
- **FR-002**: The catch-all route MUST render a dedicated 404 component with a "Page not found" message.
- **FR-003**: The 404 page MUST include a link back to the home page.
- **FR-004**: The 404 page MUST follow the design system defined in `.specify/memory/design-system.md`.
## Success Criteria
### Measurable Outcomes
- **SC-001**: Navigating to any undefined route displays the 404 page instead of a blank screen.
- **SC-002**: The home page link on the 404 page navigates back to `/` successfully.
- **SC-003**: The 404 page passes WCAG AA contrast requirements.
**Dependencies:** None (requires frontend scaffold from T-1/T-4 to be practically implementable)
**Notes:** Identified during US-1 post-review. Navigating to an unknown path currently shows a blank page because the Vue Router has no catch-all route.