Compare commits
25 Commits
958222d0c0
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ffea279b54 | |||
| 4cfac860aa | |||
| e3ca613210 | |||
| 14f11875a4 | |||
| a029e951b8 | |||
| 84feeb9997 | |||
| f3d4b5fa17 | |||
| c80074093c | |||
| 830ca55f20 | |||
| eeadaf58c7 | |||
| 387445e089 | |||
| c2bbb78b7b | |||
| 91e566efea | |||
| b8421274b4 | |||
| 747ed18945 | |||
| e9110ea143 | |||
| bd84f4e355 | |||
| 23b264e66e | |||
| cb0bcad145 | |||
| e8184be12f | |||
| a7303aa859 | |||
| 977795ffb1 | |||
| 316137bf1c | |||
| 96ef8656bd | |||
|
|
f5df2d7290 |
22
.claude/hooks/openapi-validate.sh
Executable file
22
.claude/hooks/openapi-validate.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Read hook input from stdin (JSON with tool_input.file_path)
|
||||||
|
INPUT=$(cat)
|
||||||
|
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Only run for OpenAPI spec files
|
||||||
|
case "$FILE_PATH" in
|
||||||
|
*/openapi/*.yaml|*/openapi/*.yml) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cd "$CLAUDE_PROJECT_DIR/backend"
|
||||||
|
|
||||||
|
# Run validation (zero-config: structural validity only)
|
||||||
|
if OUTPUT=$(npx @redocly/cli@latest lint src/main/resources/openapi/api.yaml --format=stylish 2>&1); then
|
||||||
|
echo '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"✓ OpenAPI spec validation passed."}}'
|
||||||
|
else
|
||||||
|
ESCAPED=$(echo "$OUTPUT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||||
|
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":$ESCAPED}}"
|
||||||
|
fi
|
||||||
@@ -13,6 +13,11 @@
|
|||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/frontend-check.sh\"",
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/frontend-check.sh\"",
|
||||||
"timeout": 120
|
"timeout": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/openapi-validate.sh\"",
|
||||||
|
"timeout": 120
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
66
.claude/skills/release/SKILL.md
Normal file
66
.claude/skills/release/SKILL.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: release
|
||||||
|
description: Create a SemVer release tag and push it to trigger the CI/CD pipeline. Use this skill when the user says "release", "tag a release", "publish version X.Y.Z", "create a release", or mentions pushing a version tag. Also trigger when the user says "/release".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Release
|
||||||
|
|
||||||
|
Create a SemVer git tag and push it to the Gitea remote, triggering the CI/CD pipeline which builds and publishes a Docker image.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The project uses a Gitea Actions pipeline (`.gitea/workflows/ci.yaml`) with three jobs:
|
||||||
|
|
||||||
|
- **backend-test**: JDK 25, `./mvnw -B verify`
|
||||||
|
- **frontend-test**: Node 24, lint + type-check + tests + build
|
||||||
|
- **build-and-publish**: Docker image build + push (only on SemVer tags)
|
||||||
|
|
||||||
|
When a tag matching `X.Y.Z` is pushed, `build-and-publish` runs after both test jobs pass. It publishes the Docker image to the Gitea container registry with four rolling tags: `X.Y.Z`, `X.Y`, `X`, and `latest`.
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
The user provides a version string as argument, e.g. `/release 0.2.0`. If no version is provided, ask for one.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
Execute these steps in order. Stop and report if any check fails.
|
||||||
|
|
||||||
|
### 1. Validate the version
|
||||||
|
|
||||||
|
The argument must be a valid SemVer string: `X.Y.Z` where X, Y, Z are non-negative integers. Reject anything else (no `v` prefix, no pre-release suffixes).
|
||||||
|
|
||||||
|
### 2. Check for existing tag
|
||||||
|
|
||||||
|
Run `git tag -l <version>`. If the tag already exists locally or on the remote, stop and tell the user.
|
||||||
|
|
||||||
|
### 3. Check working tree is clean
|
||||||
|
|
||||||
|
Run `git status --porcelain`. If there is any output, stop and warn the user about uncommitted changes. List what's dirty.
|
||||||
|
|
||||||
|
### 4. Check all commits are pushed
|
||||||
|
|
||||||
|
Compare `git rev-parse HEAD` with `git rev-parse @{upstream}`. If they differ, stop and warn the user that there are unpushed local commits. Show `git log @{upstream}..HEAD --oneline` so they can see what's pending.
|
||||||
|
|
||||||
|
### 5. Confirm with the user
|
||||||
|
|
||||||
|
Before creating the tag, show a summary and ask for confirmation:
|
||||||
|
- Version to tag: `<version>`
|
||||||
|
- Commit being tagged: short hash + subject line
|
||||||
|
- What will happen: pipeline runs tests, then builds and publishes `<registry>/<owner>/fete:<version>` (plus rolling tags)
|
||||||
|
|
||||||
|
### 6. Create and push the tag
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag <version>
|
||||||
|
git push origin <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Report success
|
||||||
|
|
||||||
|
Tell the user:
|
||||||
|
- Tag `<version>` pushed successfully
|
||||||
|
- The CI/CD pipeline is now running
|
||||||
|
- They can watch the progress in the Gitea Actions UI
|
||||||
|
- Once complete, the image will be available as `docker pull <registry>/<owner>/fete:<version>`
|
||||||
|
|
||||||
|
Do not wait for the pipeline to finish or poll its status.
|
||||||
38
.dockerignore
Normal file
38
.dockerignore
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 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
|
||||||
89
.gitea/workflows/ci.yaml
Normal file
89
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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: Generate API types
|
||||||
|
run: cd frontend && npm run generate:api
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: 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}"
|
||||||
|
docker build -t "${IMAGE}:${{ steps.semver.outputs.full }}" .
|
||||||
|
docker tag "${IMAGE}:${{ steps.semver.outputs.full }}" "${IMAGE}:${{ steps.semver.outputs.minor }}"
|
||||||
|
docker tag "${IMAGE}:${{ steps.semver.outputs.full }}" "${IMAGE}:${{ steps.semver.outputs.major }}"
|
||||||
|
docker tag "${IMAGE}:${{ steps.semver.outputs.full }}" "${IMAGE}:latest"
|
||||||
|
echo "IMAGE=${IMAGE}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Push to registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u "${{ github.repository_owner }}" --password-stdin "${IMAGE%%/*}"
|
||||||
|
docker push "${IMAGE}:${{ steps.semver.outputs.full }}"
|
||||||
|
docker push "${IMAGE}:${{ steps.semver.outputs.minor }}"
|
||||||
|
docker push "${IMAGE}:${{ steps.semver.outputs.major }}"
|
||||||
|
docker push "${IMAGE}:latest"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -49,6 +49,9 @@ npm-debug.log*
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Spring Boot local profile (developer-specific, not committed)
|
||||||
|
backend/src/main/resources/application-local.properties
|
||||||
|
|
||||||
# Editor swap files
|
# Editor swap files
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ These are the non-negotiable principles of this project. Every decision — arch
|
|||||||
### Methodology
|
### Methodology
|
||||||
|
|
||||||
- Follow Research → Spec → Test → Implement → Review. No shortcuts.
|
- Follow Research → Spec → Test → Implement → Review. No shortcuts.
|
||||||
|
- API-first development: the OpenAPI spec (`backend/src/main/resources/openapi/api.yaml`) is the single source of truth for the REST API contract. Define endpoints and schemas in the spec first, then generate backend interfaces and frontend types before writing any implementation code.
|
||||||
- Never write implementation code without a specification.
|
- Never write implementation code without a specification.
|
||||||
- Always write tests before implementation (TDD). Red → Green → Refactor.
|
- Always write tests before implementation (TDD). Red → Green → Refactor.
|
||||||
- Refactoring is permitted freely as long as it does not alter the fundamental architecture.
|
- Refactoring is permitted freely as long as it does not alter the fundamental architecture.
|
||||||
@@ -31,6 +32,11 @@ These are the non-negotiable principles of this project. Every decision — arch
|
|||||||
- For every feature, critically evaluate what data is necessary. Only store what is absolutely required for functionality.
|
- For every feature, critically evaluate what data is necessary. Only store what is absolutely required for functionality.
|
||||||
- Never include external dependencies that phone home: no CDNs, no Google Fonts, no tracking-capable libraries.
|
- Never include external dependencies that phone home: no CDNs, no Google Fonts, no tracking-capable libraries.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
- The visual design system is defined in `spec/design-system.md`. All frontend implementation must follow it.
|
||||||
|
- Color palette, typography, component patterns, and layout rules are specified there — do not deviate without explicit approval.
|
||||||
|
|
||||||
### Quality
|
### Quality
|
||||||
|
|
||||||
- KISS and grugbrain. Engineer it properly, but don't over-engineer.
|
- KISS and grugbrain. Engineer it properly, but don't over-engineer.
|
||||||
|
|||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 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
|
||||||
|
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"]
|
||||||
77
README.md
77
README.md
@@ -47,6 +47,7 @@ A privacy-focused, self-hostable web app for event announcements and RSVPs. An a
|
|||||||
|
|
||||||
- Java (latest LTS) + Maven wrapper (`./mvnw`, included)
|
- Java (latest LTS) + Maven wrapper (`./mvnw`, included)
|
||||||
- Node.js (latest LTS) + npm
|
- Node.js (latest LTS) + npm
|
||||||
|
- Docker (for running backend tests via Testcontainers)
|
||||||
|
|
||||||
### Project structure
|
### Project structure
|
||||||
|
|
||||||
@@ -68,6 +69,26 @@ cd backend && ./mvnw test
|
|||||||
cd frontend && npm run test:unit
|
cd frontend && npm run test:unit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Running the backend locally
|
||||||
|
|
||||||
|
**Option A: Without external PostgreSQL (Testcontainers)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && ./mvnw spring-boot:test-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Starts the app with a Testcontainers-managed PostgreSQL that is created and destroyed automatically. Requires Docker.
|
||||||
|
|
||||||
|
**Option B: With 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
|
||||||
|
```
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -129,6 +150,62 @@ ArchUnit enforces hexagonal boundaries: domain must not depend on adapters, appl
|
|||||||
|---------------------|------------------|-------------------|
|
|---------------------|------------------|-------------------|
|
||||||
| Prettier | `npm run format` | Formatting issues |
|
| Prettier | `npm run format` | Formatting issues |
|
||||||
|
|
||||||
|
### OpenAPI Spec (YAML)
|
||||||
|
|
||||||
|
**After editing an `openapi/*.yaml` file** (PostToolUse hook):
|
||||||
|
|
||||||
|
| What | Command | Fails on |
|
||||||
|
|---------------------|--------------------------|-----------------------------------|
|
||||||
|
| Redocly CLI | `redocly lint api.yaml` | Structural and ruleset violations |
|
||||||
|
|
||||||
|
Validates the OpenAPI 3.1 spec against the Redocly `recommended` ruleset (with `security-defined` disabled, since endpoints are intentionally public). Runs via `npx @redocly/cli@latest`.
|
||||||
|
|
||||||
|
## 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: git.bahamut.nitrix.one/nitrix/fete:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: jdbc:postgresql://db:5432/fete
|
||||||
|
DATABASE_USERNAME: fete
|
||||||
|
DATABASE_PASSWORD: changeme
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
fete-db:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|---------------------|----------|---------|-----------------------------------|
|
||||||
|
| `DATABASE_URL` | Yes | — | JDBC connection string |
|
||||||
|
| `DATABASE_USERNAME` | Yes | — | Database username |
|
||||||
|
| `DATABASE_PASSWORD` | Yes | — | Database password |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GPL — see [LICENSE](LICENSE) for details.
|
GPL — see [LICENSE](LICENSE) for details.
|
||||||
|
|||||||
@@ -37,6 +37,22 @@
|
|||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
@@ -49,6 +65,24 @@
|
|||||||
<version>1.4.1</version>
|
<version>1.4.1</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<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>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
5
backend/redocly.yaml
Normal file
5
backend/redocly.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
extends:
|
||||||
|
- recommended
|
||||||
|
|
||||||
|
rules:
|
||||||
|
security-defined: off
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import de.fete.adapter.in.web.api.EventsApi;
|
||||||
|
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||||
|
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||||
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.port.in.CreateEventUseCase;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** REST controller for event operations. */
|
||||||
|
@RestController
|
||||||
|
public class EventController implements EventsApi {
|
||||||
|
|
||||||
|
private final CreateEventUseCase createEventUseCase;
|
||||||
|
|
||||||
|
/** Creates a new controller with the given use case. */
|
||||||
|
public EventController(CreateEventUseCase createEventUseCase) {
|
||||||
|
this.createEventUseCase = createEventUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<CreateEventResponse> createEvent(
|
||||||
|
CreateEventRequest request) {
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
request.getTitle(),
|
||||||
|
request.getDescription(),
|
||||||
|
request.getDateTime(),
|
||||||
|
request.getLocation(),
|
||||||
|
request.getExpiryDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
Event event = createEventUseCase.createEvent(command);
|
||||||
|
|
||||||
|
var response = new CreateEventResponse();
|
||||||
|
response.setEventToken(event.getEventToken());
|
||||||
|
response.setOrganizerToken(event.getOrganizerToken());
|
||||||
|
response.setTitle(event.getTitle());
|
||||||
|
response.setDateTime(event.getDateTime());
|
||||||
|
response.setExpiryDate(event.getExpiryDate());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import de.fete.application.service.ExpiryDateInPastException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.context.request.WebRequest;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||||
|
|
||||||
|
/** Global exception handler producing RFC 9457 Problem Details responses. */
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||||
|
MethodArgumentNotValidException ex,
|
||||||
|
HttpHeaders headers,
|
||||||
|
HttpStatusCode status,
|
||||||
|
WebRequest request) {
|
||||||
|
|
||||||
|
ProblemDetail problemDetail = ex.getBody();
|
||||||
|
problemDetail.setTitle("Validation Failed");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:validation-error"));
|
||||||
|
|
||||||
|
List<Map<String, String>> fieldErrors = ex.getBindingResult()
|
||||||
|
.getFieldErrors()
|
||||||
|
.stream()
|
||||||
|
.map(fe -> Map.of(
|
||||||
|
"field", fe.getField(),
|
||||||
|
"message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid"
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
problemDetail.setProperty("fieldErrors", fieldErrors);
|
||||||
|
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles expiry date validation failures. */
|
||||||
|
@ExceptionHandler(ExpiryDateInPastException.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
|
||||||
|
ExpiryDateInPastException ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Invalid Expiry Date");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:expiry-date-in-past"));
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catches all unhandled exceptions. */
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ProblemDetail> handleAll(Exception ex) {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"An unexpected error occurred.");
|
||||||
|
problemDetail.setTitle("Internal Server Error");
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||||
|
.body(problemDetail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** JPA entity mapping to the events table. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "events")
|
||||||
|
public class EventJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "event_token", nullable = false, unique = true)
|
||||||
|
private UUID eventToken;
|
||||||
|
|
||||||
|
@Column(name = "organizer_token", nullable = false, unique = true)
|
||||||
|
private UUID organizerToken;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(length = 2000)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "date_time", nullable = false)
|
||||||
|
private OffsetDateTime dateTime;
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
@Column(name = "expiry_date", nullable = false)
|
||||||
|
private LocalDate expiryDate;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
/** Returns the internal database ID. */
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the internal database ID. */
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the public event token. */
|
||||||
|
public UUID getEventToken() {
|
||||||
|
return eventToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the public event token. */
|
||||||
|
public void setEventToken(UUID eventToken) {
|
||||||
|
this.eventToken = eventToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the secret organizer token. */
|
||||||
|
public UUID getOrganizerToken() {
|
||||||
|
return organizerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the secret organizer token. */
|
||||||
|
public void setOrganizerToken(UUID organizerToken) {
|
||||||
|
this.organizerToken = organizerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event title. */
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event title. */
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event description. */
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event description. */
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event date and time. */
|
||||||
|
public OffsetDateTime getDateTime() {
|
||||||
|
return dateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event date and time. */
|
||||||
|
public void setDateTime(OffsetDateTime dateTime) {
|
||||||
|
this.dateTime = dateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event location. */
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event location. */
|
||||||
|
public void setLocation(String location) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the expiry date. */
|
||||||
|
public LocalDate getExpiryDate() {
|
||||||
|
return expiryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the expiry date. */
|
||||||
|
public void setExpiryDate(LocalDate expiryDate) {
|
||||||
|
this.expiryDate = expiryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the creation timestamp. */
|
||||||
|
public OffsetDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the creation timestamp. */
|
||||||
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/** Spring Data JPA repository for event entities. */
|
||||||
|
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
||||||
|
|
||||||
|
/** Finds an event by its public event token. */
|
||||||
|
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/** Persistence adapter implementing the EventRepository outbound port. */
|
||||||
|
@Repository
|
||||||
|
public class EventPersistenceAdapter implements EventRepository {
|
||||||
|
|
||||||
|
private final EventJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
/** Creates a new adapter with the given JPA repository. */
|
||||||
|
public EventPersistenceAdapter(EventJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Event save(Event event) {
|
||||||
|
EventJpaEntity entity = toEntity(event);
|
||||||
|
EventJpaEntity saved = jpaRepository.save(entity);
|
||||||
|
return toDomain(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Event> findByEventToken(UUID eventToken) {
|
||||||
|
return jpaRepository.findByEventToken(eventToken).map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventJpaEntity toEntity(Event event) {
|
||||||
|
var entity = new EventJpaEntity();
|
||||||
|
entity.setId(event.getId());
|
||||||
|
entity.setEventToken(event.getEventToken());
|
||||||
|
entity.setOrganizerToken(event.getOrganizerToken());
|
||||||
|
entity.setTitle(event.getTitle());
|
||||||
|
entity.setDescription(event.getDescription());
|
||||||
|
entity.setDateTime(event.getDateTime());
|
||||||
|
entity.setLocation(event.getLocation());
|
||||||
|
entity.setExpiryDate(event.getExpiryDate());
|
||||||
|
entity.setCreatedAt(event.getCreatedAt());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Event toDomain(EventJpaEntity entity) {
|
||||||
|
var event = new Event();
|
||||||
|
event.setId(entity.getId());
|
||||||
|
event.setEventToken(entity.getEventToken());
|
||||||
|
event.setOrganizerToken(entity.getOrganizerToken());
|
||||||
|
event.setTitle(entity.getTitle());
|
||||||
|
event.setDescription(entity.getDescription());
|
||||||
|
event.setDateTime(entity.getDateTime());
|
||||||
|
event.setLocation(entity.getLocation());
|
||||||
|
event.setExpiryDate(entity.getExpiryDate());
|
||||||
|
event.setCreatedAt(entity.getCreatedAt());
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.port.in.CreateEventUseCase;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Application service implementing event creation. */
|
||||||
|
@Service
|
||||||
|
public class EventService implements CreateEventUseCase {
|
||||||
|
|
||||||
|
private final EventRepository eventRepository;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
/** Creates a new EventService with the given repository and clock. */
|
||||||
|
public EventService(EventRepository eventRepository, Clock clock) {
|
||||||
|
this.eventRepository = eventRepository;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Event createEvent(CreateEventCommand command) {
|
||||||
|
if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
|
||||||
|
throw new ExpiryDateInPastException(command.expiryDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
var event = new Event();
|
||||||
|
event.setEventToken(UUID.randomUUID());
|
||||||
|
event.setOrganizerToken(UUID.randomUUID());
|
||||||
|
event.setTitle(command.title());
|
||||||
|
event.setDescription(command.description());
|
||||||
|
event.setDateTime(command.dateTime());
|
||||||
|
event.setLocation(command.location());
|
||||||
|
event.setExpiryDate(command.expiryDate());
|
||||||
|
event.setCreatedAt(OffsetDateTime.now(clock));
|
||||||
|
|
||||||
|
return eventRepository.save(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** Thrown when an event's expiry date is not in the future. */
|
||||||
|
public class ExpiryDateInPastException extends RuntimeException {
|
||||||
|
|
||||||
|
private final LocalDate expiryDate;
|
||||||
|
|
||||||
|
/** Creates a new exception for the given invalid expiry date. */
|
||||||
|
public ExpiryDateInPastException(LocalDate expiryDate) {
|
||||||
|
super("Expiry date must be in the future: " + expiryDate);
|
||||||
|
this.expiryDate = expiryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the invalid expiry date. */
|
||||||
|
public LocalDate getExpiryDate() {
|
||||||
|
return expiryDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/src/main/java/de/fete/config/WebConfig.java
Normal file
47
backend/src/main/java/de/fete/config/WebConfig.java
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package de.fete.config;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.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;
|
||||||
|
|
||||||
|
/** Configures API path prefix and SPA static resource serving. */
|
||||||
|
@Configuration
|
||||||
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
Clock clock() {
|
||||||
|
return Clock.systemDefaultZone();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
if (requested.exists() && requested.isReadable()) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
Resource index = new ClassPathResource("/static/index.html");
|
||||||
|
return (index.exists() && index.isReadable()) ? index : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.fete.domain.model;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/** Command carrying the data needed to create an event. */
|
||||||
|
public record CreateEventCommand(
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
OffsetDateTime dateTime,
|
||||||
|
String location,
|
||||||
|
LocalDate expiryDate
|
||||||
|
) {}
|
||||||
109
backend/src/main/java/de/fete/domain/model/Event.java
Normal file
109
backend/src/main/java/de/fete/domain/model/Event.java
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package de.fete.domain.model;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Domain entity representing an event. */
|
||||||
|
public class Event {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private UUID eventToken;
|
||||||
|
private UUID organizerToken;
|
||||||
|
private String title;
|
||||||
|
private String description;
|
||||||
|
private OffsetDateTime dateTime;
|
||||||
|
private String location;
|
||||||
|
private LocalDate expiryDate;
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
/** Returns the internal database ID. */
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the internal database ID. */
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the public event token (UUID). */
|
||||||
|
public UUID getEventToken() {
|
||||||
|
return eventToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the public event token. */
|
||||||
|
public void setEventToken(UUID eventToken) {
|
||||||
|
this.eventToken = eventToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the secret organizer token (UUID). */
|
||||||
|
public UUID getOrganizerToken() {
|
||||||
|
return organizerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the secret organizer token. */
|
||||||
|
public void setOrganizerToken(UUID organizerToken) {
|
||||||
|
this.organizerToken = organizerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event title. */
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event title. */
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event description. */
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event description. */
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event date and time with UTC offset. */
|
||||||
|
public OffsetDateTime getDateTime() {
|
||||||
|
return dateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event date and time. */
|
||||||
|
public void setDateTime(OffsetDateTime dateTime) {
|
||||||
|
this.dateTime = dateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the event location. */
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the event location. */
|
||||||
|
public void setLocation(String location) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the expiry date after which event data is deleted. */
|
||||||
|
public LocalDate getExpiryDate() {
|
||||||
|
return expiryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the expiry date. */
|
||||||
|
public void setExpiryDate(LocalDate expiryDate) {
|
||||||
|
this.expiryDate = expiryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the creation timestamp. */
|
||||||
|
public OffsetDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the creation timestamp. */
|
||||||
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.fete.domain.port.in;
|
||||||
|
|
||||||
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
|
||||||
|
/** Inbound port for creating a new event. */
|
||||||
|
public interface CreateEventUseCase {
|
||||||
|
|
||||||
|
/** Creates an event from the given command and returns the persisted event. */
|
||||||
|
Event createEvent(CreateEventCommand command);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.fete.domain.port.out;
|
||||||
|
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Outbound port for persisting and retrieving events. */
|
||||||
|
public interface EventRepository {
|
||||||
|
|
||||||
|
/** Persists the given event and returns it with generated fields populated. */
|
||||||
|
Event save(Event event);
|
||||||
|
|
||||||
|
/** Finds an event by its public event token. */
|
||||||
|
Optional<Event> findByEventToken(UUID eventToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 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
|
||||||
4
backend/src/main/resources/application-prod.properties
Normal file
4
backend/src/main/resources/application-prod.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Database (required)
|
||||||
|
spring.datasource.url=${DATABASE_URL}
|
||||||
|
spring.datasource.username=${DATABASE_USERNAME}
|
||||||
|
spring.datasource.password=${DATABASE_PASSWORD}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
spring.application.name=fete
|
spring.application.name=fete
|
||||||
server.servlet.context-path=/api
|
|
||||||
|
|
||||||
|
# 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.endpoints.web.exposure.include=health
|
||||||
management.endpoint.health.show-details=never
|
management.endpoint.health.show-details=never
|
||||||
|
|||||||
12
backend/src/main/resources/db/changelog/000-baseline.xml
Normal file
12
backend/src/main/resources/db/changelog/000-baseline.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="000-baseline" author="nitrix">
|
||||||
|
<comment>Baseline changeset — Liquibase tooling verification</comment>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="001-create-events-table" author="fete">
|
||||||
|
<createTable tableName="events">
|
||||||
|
<column name="id" type="bigserial" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="event_token" type="uuid">
|
||||||
|
<constraints nullable="false" unique="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="organizer_token" type="uuid">
|
||||||
|
<constraints nullable="false" unique="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="title" type="varchar(200)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="description" type="varchar(2000)"/>
|
||||||
|
<column name="date_time" type="timestamptz">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="location" type="varchar(500)"/>
|
||||||
|
<column name="expiry_date" type="date">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<createIndex tableName="events" indexName="idx_events_event_token">
|
||||||
|
<column name="event_token"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<createIndex tableName="events" indexName="idx_events_expiry_date">
|
||||||
|
<column name="expiry_date"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?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"/>
|
||||||
|
<include file="db/changelog/001-create-events-table.xml"/>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -11,27 +11,120 @@ servers:
|
|||||||
- url: /api
|
- url: /api
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/health:
|
/events:
|
||||||
get:
|
post:
|
||||||
operationId: getHealth
|
operationId: createEvent
|
||||||
summary: Health check
|
summary: Create a new event
|
||||||
tags:
|
tags:
|
||||||
- health
|
- events
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateEventRequest"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"201":
|
||||||
description: Service is healthy
|
description: Event created successfully
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/HealthResponse"
|
$ref: "#/components/schemas/CreateEventResponse"
|
||||||
|
"400":
|
||||||
|
description: Validation failed
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
HealthResponse:
|
CreateEventRequest:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- status
|
- title
|
||||||
|
- dateTime
|
||||||
|
- expiryDate
|
||||||
properties:
|
properties:
|
||||||
status:
|
title:
|
||||||
type: string
|
type: string
|
||||||
example: UP
|
minLength: 1
|
||||||
|
maxLength: 200
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
maxLength: 2000
|
||||||
|
dateTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Event date and time with UTC offset (ISO 8601)
|
||||||
|
example: "2026-03-15T20:00:00+01:00"
|
||||||
|
location:
|
||||||
|
type: string
|
||||||
|
maxLength: 500
|
||||||
|
expiryDate:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Date after which event data is deleted. Must be in the future.
|
||||||
|
example: "2026-06-15"
|
||||||
|
|
||||||
|
CreateEventResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- eventToken
|
||||||
|
- organizerToken
|
||||||
|
- title
|
||||||
|
- dateTime
|
||||||
|
- expiryDate
|
||||||
|
properties:
|
||||||
|
eventToken:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Public token for the event URL
|
||||||
|
organizerToken:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Secret token for organizer access
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
dateTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
expiryDate:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
|
||||||
|
ProblemDetail:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
default: "about:blank"
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
detail:
|
||||||
|
type: string
|
||||||
|
instance:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
ValidationProblemDetail:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/ProblemDetail"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
fieldErrors:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- field
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
class FeteApplicationTest {
|
class FeteApplicationTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|||||||
14
backend/src/test/java/de/fete/TestFeteApplication.java
Normal file
14
backend/src/test/java/de/fete/TestFeteApplication.java
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package de.fete;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
|
||||||
|
/** Test entry point — starts the app with Testcontainers PostgreSQL. */
|
||||||
|
public class TestFeteApplication {
|
||||||
|
|
||||||
|
/** Starts the application with Testcontainers PostgreSQL. */
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.from(FeteApplication::main)
|
||||||
|
.with(TestcontainersConfig.class)
|
||||||
|
.run(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/src/test/java/de/fete/TestcontainersConfig.java
Normal file
17
backend/src/test/java/de/fete/TestcontainersConfig.java
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import de.fete.TestcontainersConfig;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
|
class EventControllerIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventWithValidBody() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "Birthday Party",
|
||||||
|
"description": "Come celebrate!",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"location": "Berlin",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
||||||
|
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.expiryDate").isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventWithOptionalFieldsNull() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "Minimal Event",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.title").value("Minimal Event"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventMissingTitleReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.title").value("Validation Failed"))
|
||||||
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventMissingDateTimeReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "No Date",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventMissingExpiryDateReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "No Expiry",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventExpiryDateInPastReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "Past Expiry",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "2025-01-01"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventExpiryDateTodayReturns400() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "Today Expiry",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||||
|
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void errorResponseContentTypeIsProblemJson() throws Exception {
|
||||||
|
String body =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||||
|
"expiryDate": "%s"
|
||||||
|
}
|
||||||
|
""".formatted(LocalDate.now().plusDays(30));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import de.fete.TestcontainersConfig;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
|
class EventPersistenceAdapterTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EventRepository eventRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveReturnsEventWithGeneratedId() {
|
||||||
|
Event event = buildEvent();
|
||||||
|
|
||||||
|
Event saved = eventRepository.save(event);
|
||||||
|
|
||||||
|
assertThat(saved.getId()).isNotNull();
|
||||||
|
assertThat(saved.getTitle()).isEqualTo("Test Event");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savedEventIsFoundByEventToken() {
|
||||||
|
Event event = buildEvent();
|
||||||
|
Event saved = eventRepository.save(event);
|
||||||
|
|
||||||
|
Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
|
||||||
|
|
||||||
|
assertThat(found).isPresent();
|
||||||
|
assertThat(found.get().getTitle()).isEqualTo("Test Event");
|
||||||
|
assertThat(found.get().getId()).isEqualTo(saved.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByUnknownEventTokenReturnsEmpty() {
|
||||||
|
Optional<Event> found = eventRepository.findByEventToken(UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(found).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allFieldsRoundTripCorrectly() {
|
||||||
|
OffsetDateTime dateTime =
|
||||||
|
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2));
|
||||||
|
LocalDate expiryDate = LocalDate.of(2026, 7, 15);
|
||||||
|
OffsetDateTime createdAt =
|
||||||
|
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
||||||
|
|
||||||
|
var event = new Event();
|
||||||
|
event.setEventToken(UUID.randomUUID());
|
||||||
|
event.setOrganizerToken(UUID.randomUUID());
|
||||||
|
event.setTitle("Full Event");
|
||||||
|
event.setDescription("A detailed description");
|
||||||
|
event.setDateTime(dateTime);
|
||||||
|
event.setLocation("Berlin, Germany");
|
||||||
|
event.setExpiryDate(expiryDate);
|
||||||
|
event.setCreatedAt(createdAt);
|
||||||
|
|
||||||
|
Event saved = eventRepository.save(event);
|
||||||
|
Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
|
||||||
|
assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
|
||||||
|
assertThat(found.getTitle()).isEqualTo("Full Event");
|
||||||
|
assertThat(found.getDescription()).isEqualTo("A detailed description");
|
||||||
|
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
||||||
|
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
|
||||||
|
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
|
||||||
|
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Event buildEvent() {
|
||||||
|
var event = new Event();
|
||||||
|
event.setEventToken(UUID.randomUUID());
|
||||||
|
event.setOrganizerToken(UUID.randomUUID());
|
||||||
|
event.setTitle("Test Event");
|
||||||
|
event.setDescription("Test description");
|
||||||
|
event.setDateTime(OffsetDateTime.now().plusDays(7));
|
||||||
|
event.setLocation("Somewhere");
|
||||||
|
event.setExpiryDate(LocalDate.now().plusDays(30));
|
||||||
|
event.setCreatedAt(OffsetDateTime.now());
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import de.fete.domain.model.CreateEventCommand;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class EventServiceTest {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
|
||||||
|
private static final Instant FIXED_INSTANT =
|
||||||
|
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
|
||||||
|
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EventRepository eventRepository;
|
||||||
|
|
||||||
|
private EventService eventService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
eventService = new EventService(eventRepository, FIXED_CLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createEventWithValidCommand() {
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Birthday Party",
|
||||||
|
"Come celebrate!",
|
||||||
|
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||||
|
"Berlin",
|
||||||
|
LocalDate.of(2026, 7, 15)
|
||||||
|
);
|
||||||
|
|
||||||
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
|
assertThat(result.getTitle()).isEqualTo("Birthday Party");
|
||||||
|
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
||||||
|
assertThat(result.getLocation()).isEqualTo("Berlin");
|
||||||
|
assertThat(result.getEventToken()).isNotNull();
|
||||||
|
assertThat(result.getOrganizerToken()).isNotNull();
|
||||||
|
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventTokenAndOrganizerTokenAreDifferent() {
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Test", null,
|
||||||
|
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||||
|
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
||||||
|
);
|
||||||
|
|
||||||
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
|
assertThat(result.getEventToken()).isNotEqualTo(result.getOrganizerToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void repositorySaveCalledExactlyOnce() {
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Test", null,
|
||||||
|
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||||
|
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
||||||
|
);
|
||||||
|
|
||||||
|
eventService.createEvent(command);
|
||||||
|
|
||||||
|
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||||
|
verify(eventRepository, times(1)).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getTitle()).isEqualTo("Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiryDateTodayThrowsException() {
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Test", null,
|
||||||
|
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||||
|
LocalDate.now(FIXED_CLOCK)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||||
|
.isInstanceOf(ExpiryDateInPastException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiryDateInPastThrowsException() {
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Test", null,
|
||||||
|
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||||
|
LocalDate.now(FIXED_CLOCK).minusDays(5)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||||
|
.isInstanceOf(ExpiryDateInPastException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiryDateTomorrowSucceeds() {
|
||||||
|
when(eventRepository.save(any(Event.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
var command = new CreateEventCommand(
|
||||||
|
"Test", null,
|
||||||
|
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||||
|
LocalDate.now(FIXED_CLOCK).plusDays(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
|
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6));
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/src/test/java/de/fete/config/WebConfigTest.java
Normal file
36
backend/src/test/java/de/fete/config/WebConfigTest.java
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package de.fete.config;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import de.fete.TestcontainersConfig;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
|
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 {
|
||||||
|
// /events without /api prefix should not resolve to the API endpoint
|
||||||
|
mockMvc.perform(get("/events"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
}
|
||||||
366
docs/agents/plan/2026-03-04-t2-docker-deployment.md
Normal file
366
docs/agents/plan/2026-03-04-t2-docker-deployment.md
Normal 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
|
||||||
266
docs/agents/plan/2026-03-04-t3-cicd-pipeline.md
Normal file
266
docs/agents/plan/2026-03-04-t3-cicd-pipeline.md
Normal 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`
|
||||||
567
docs/agents/plan/2026-03-04-t4-development-infrastructure.md
Normal file
567
docs/agents/plan/2026-03-04-t4-development-infrastructure.md
Normal 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
|
||||||
1152
docs/agents/plan/2026-03-04-us1-create-event.md
Normal file
1152
docs/agents/plan/2026-03-04-us1-create-event.md
Normal file
File diff suppressed because it is too large
Load Diff
199
docs/agents/plan/2026-03-05-us1-post-review-fixes.md
Normal file
199
docs/agents/plan/2026-03-05-us1-post-review-fixes.md
Normal 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
|
||||||
109
docs/agents/plan/2026-03-05-us1-review-fixes.md
Normal file
109
docs/agents/plan/2026-03-05-us1-review-fixes.md
Normal 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 (01–08)
|
||||||
|
- **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
|
||||||
107
docs/agents/research/2026-03-04-datetime-best-practices.md
Normal file
107
docs/agents/research/2026-03-04-datetime-best-practices.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
date: 2026-03-04T21:15:50+00:00
|
||||||
|
git_commit: b8421274b47c6d1778b83c6b0acb70fd82891e71
|
||||||
|
branch: master
|
||||||
|
topic: "Date/Time Handling Best Practices for the fete Stack"
|
||||||
|
tags: [research, datetime, java, postgresql, openapi, typescript]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: Date/Time Handling Best Practices
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
|
||||||
|
What are the best practices for handling dates and times across the full fete stack (Java 25 / Spring Boot 3.5.x / PostgreSQL / OpenAPI 3.1 / Vue 3 / TypeScript)?
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The project has two distinct date/time concepts: **event date/time** (when something happens) and **expiry date** (after which data is deleted). These map to different types at every layer. The recommendations align Java types, PostgreSQL column types, OpenAPI formats, and TypeScript representations into a consistent stack-wide approach.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### Type Mapping Across the Stack
|
||||||
|
|
||||||
|
| Concept | Java | PostgreSQL | OpenAPI | TypeScript | Example |
|
||||||
|
|---------|------|------------|---------|------------|---------|
|
||||||
|
| Event date/time | `OffsetDateTime` | `timestamptz` | `string`, `format: date-time` | `string` | `2026-03-15T20:00:00+01:00` |
|
||||||
|
| Expiry date | `LocalDate` | `date` | `string`, `format: date` | `string` | `2026-06-15` |
|
||||||
|
| Audit timestamps (createdAt, etc.) | `OffsetDateTime` | `timestamptz` | `string`, `format: date-time` | `string` | `2026-03-04T14:22:00Z` |
|
||||||
|
|
||||||
|
### Event Date/Time: `OffsetDateTime` + `timestamptz`
|
||||||
|
|
||||||
|
**Why `OffsetDateTime`, not `LocalDateTime`:**
|
||||||
|
|
||||||
|
- PostgreSQL best practice explicitly recommends `timestamptz` over `timestamp` — the PostgreSQL wiki says ["don't use `timestamp`"](https://wiki.postgresql.org/wiki/Don't_Do_This). `timestamptz` maps naturally to `OffsetDateTime`.
|
||||||
|
- Hibernate 6 (Spring Boot 3.5.x) has native `OffsetDateTime` ↔ `timestamptz` support. `LocalDateTime` requires extra care to avoid silent timezone bugs at the JDBC driver level.
|
||||||
|
- An ISO 8601 string with offset (`2026-03-15T20:00:00+01:00`) is unambiguous in the API. A bare `LocalDateTime` string forces the client to guess the timezone.
|
||||||
|
- The OpenAPI `date-time` format and `openapi-generator` default to `OffsetDateTime` in Java — no custom type mappings needed.
|
||||||
|
|
||||||
|
**Why not `ZonedDateTime`:** Carries IANA zone IDs (e.g. `Europe/Berlin`) which add complexity without value for this use case. Worse JDBC support than `OffsetDateTime`.
|
||||||
|
|
||||||
|
**How PostgreSQL stores it:** `timestamptz` does **not** store the timezone. It converts input to UTC and stores UTC. On retrieval, it converts to the session's timezone setting. The offset is preserved in the Java `OffsetDateTime` via the JDBC driver.
|
||||||
|
|
||||||
|
**Practical flow:** The frontend sends the offset based on the organizer's browser locale. The server stores UTC. Display-side conversion happens in the frontend.
|
||||||
|
|
||||||
|
### Expiry Date: `LocalDate` + `date`
|
||||||
|
|
||||||
|
The expiry date is a calendar-date concept ("after which day should data be deleted"), not a point-in-time. A cleanup job runs periodically and deletes events where `expiryDate < today`. Sub-day precision adds no value and complicates the UX.
|
||||||
|
|
||||||
|
### Jackson Serialization (Spring Boot 3.5.x)
|
||||||
|
|
||||||
|
Spring Boot 3.x auto-configures `jackson-datatype-jsr310` (JavaTimeModule) and disables `WRITE_DATES_AS_TIMESTAMPS` by default:
|
||||||
|
|
||||||
|
- `OffsetDateTime` serializes to `"2026-03-15T20:00:00+01:00"` (ISO 8601 string)
|
||||||
|
- `LocalDate` serializes to `"2026-06-15"`
|
||||||
|
|
||||||
|
No additional configuration needed. For explicitness, can add to `application.properties`:
|
||||||
|
```properties
|
||||||
|
spring.jackson.serialization.write-dates-as-timestamps=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hibernate 6 Configuration
|
||||||
|
|
||||||
|
With Hibernate 6, `OffsetDateTime` maps to `timestamptz` using the `NATIVE` timezone storage strategy by default on PostgreSQL. Can be made explicit:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
spring.jpa.properties.hibernate.timezone.default_storage=NATIVE
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells Hibernate to use the database's native `TIMESTAMP WITH TIME ZONE` type directly.
|
||||||
|
|
||||||
|
### OpenAPI Schema Definitions
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Event date/time
|
||||||
|
eventDateTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: "2026-03-15T20:00:00+01:00"
|
||||||
|
|
||||||
|
# Expiry date
|
||||||
|
expiryDate:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
example: "2026-06-15"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code-generation mapping (defaults, no customization needed):**
|
||||||
|
|
||||||
|
| OpenAPI format | Java type (openapi-generator) | TypeScript type (openapi-typescript) |
|
||||||
|
|---------------|-------------------------------|--------------------------------------|
|
||||||
|
| `date-time` | `java.time.OffsetDateTime` | `string` |
|
||||||
|
| `date` | `java.time.LocalDate` | `string` |
|
||||||
|
|
||||||
|
### Frontend (TypeScript)
|
||||||
|
|
||||||
|
`openapi-typescript` generates `string` for both `format: date-time` and `format: date`. This is correct — JSON has no native date type, so dates travel as strings. Parsing to `Date` objects happens explicitly at the application boundary when needed (e.g. for display formatting).
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [PostgreSQL Wiki: Don't Do This](https://wiki.postgresql.org/wiki/Don't_Do_This) — recommends `timestamptz` over `timestamp`
|
||||||
|
- [PostgreSQL Docs: Date/Time Types](https://www.postgresql.org/docs/current/datatype-datetime.html)
|
||||||
|
- [Thorben Janssen: Hibernate 6 OffsetDateTime and ZonedDateTime](https://thorben-janssen.com/hibernate-6-offsetdatetime-and-zoneddatetime/)
|
||||||
|
- [Baeldung: OffsetDateTime Serialization With Jackson](https://www.baeldung.com/java-jackson-offsetdatetime)
|
||||||
|
- [Baeldung: Map Date Types With OpenAPI Generator](https://www.baeldung.com/openapi-map-date-types)
|
||||||
|
- [Baeldung: ZonedDateTime vs OffsetDateTime](https://www.baeldung.com/java-zoneddatetime-offsetdatetime)
|
||||||
|
- [Reflectoring: Handling Timezones in Spring Boot](https://reflectoring.io/spring-timezones/)
|
||||||
|
- [openapi-typescript documentation](https://openapi-ts.dev/)
|
||||||
215
docs/agents/research/2026-03-04-openapi-validation-pipeline.md
Normal file
215
docs/agents/research/2026-03-04-openapi-validation-pipeline.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
---
|
||||||
|
date: "2026-03-04T22:27:37.933286+00:00"
|
||||||
|
git_commit: 91e566efea0cbf53ba06a29b63317b7435609bd8
|
||||||
|
branch: master
|
||||||
|
topic: "Automatic OpenAPI Validation Pipelines for Backpressure Hooks"
|
||||||
|
tags: [research, openapi, validation, hooks, backpressure, linting]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: Automatic OpenAPI Validation Pipelines
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
|
||||||
|
What automatic validation pipelines exist for OpenAPI specs that can be integrated into the current Claude Code backpressure hook setup, running after the OpenAPI spec has been modified?
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The project already has a PostToolUse hook system that runs backend compile checks and frontend lint/type-checks after Edit/Write operations. Adding OpenAPI spec validation requires a new hook script that triggers specifically when `api.yaml` is modified. Several CLI tools support OpenAPI 3.1.0 validation — **Redocly CLI** is the strongest fit given the existing Node.js toolchain, MIT license, active maintenance, and zero-config baseline.
|
||||||
|
|
||||||
|
## Current Backpressure Setup
|
||||||
|
|
||||||
|
### Hook Architecture (`.claude/settings.json`)
|
||||||
|
|
||||||
|
The project uses Claude Code hooks for automated quality gates:
|
||||||
|
|
||||||
|
| Hook Event | Trigger | Scripts |
|
||||||
|
|---|---|---|
|
||||||
|
| `PostToolUse` | `Edit\|Write` tool calls | `backend-compile-check.sh`, `frontend-check.sh` |
|
||||||
|
| `Stop` | Agent attempts to stop | `run-tests.sh` |
|
||||||
|
|
||||||
|
### How Hooks Work
|
||||||
|
|
||||||
|
Each hook script:
|
||||||
|
1. Reads JSON from stdin containing `tool_input.file_path`
|
||||||
|
2. Pattern-matches the file path to decide if it should run
|
||||||
|
3. Executes validation (compile, lint, type-check, test)
|
||||||
|
4. Returns JSON with either success message or failure details
|
||||||
|
5. On failure: outputs `hookSpecificOutput` with error context (PostToolUse) or `{"decision":"block"}` (Stop)
|
||||||
|
|
||||||
|
### Existing Pattern for File Matching
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# backend-compile-check.sh — matches Java files
|
||||||
|
case "$FILE_PATH" in
|
||||||
|
*/backend/src/*.java|backend/src/*.java) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# frontend-check.sh — matches TS/Vue files
|
||||||
|
case "$FILE_PATH" in
|
||||||
|
*/frontend/src/*.ts|*/frontend/src/*.vue|frontend/src/*.ts|frontend/src/*.vue) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
```
|
||||||
|
|
||||||
|
An OpenAPI validation hook would use the same pattern:
|
||||||
|
```bash
|
||||||
|
case "$FILE_PATH" in
|
||||||
|
*/openapi/api.yaml|*/openapi/*.yaml) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing OpenAPI Tooling in the Project
|
||||||
|
|
||||||
|
- **Backend:** `openapi-generator-maven-plugin` v7.20.0 generates Spring interfaces from `api.yaml` (`pom.xml:149-178`)
|
||||||
|
- **Frontend:** `openapi-typescript` v7.13.0 generates TypeScript types; `openapi-fetch` v0.17.0 provides type-safe client
|
||||||
|
- **No validation/linting tools** currently installed — no Redocly, Spectral, or other linter config exists
|
||||||
|
|
||||||
|
## Tool Evaluation
|
||||||
|
|
||||||
|
### Redocly CLI (`@redocly/cli`)
|
||||||
|
|
||||||
|
| Attribute | Value |
|
||||||
|
|---|---|
|
||||||
|
| OpenAPI 3.1 | Full support |
|
||||||
|
| Install | `npm install -g @redocly/cli` or `npx @redocly/cli@latest` |
|
||||||
|
| CLI | `redocly lint api.yaml` |
|
||||||
|
| License | MIT |
|
||||||
|
| Maintenance | Very active — latest v2.20.3 (2026-03-03), daily/weekly releases |
|
||||||
|
| GitHub | ~1.4k stars (Redocly ecosystem: 24k+ combined) |
|
||||||
|
|
||||||
|
**Checks:** Structural validity against OAS schema, configurable linting rules (naming, descriptions, operation IDs, security), style/consistency enforcement. Built-in rulesets: `minimal`, `recommended`, `recommended-strict`. Zero-config baseline works immediately. Custom rules via `redocly.yaml`.
|
||||||
|
|
||||||
|
**Fit for this project:** Node.js already in the toolchain (frontend). `npx` form requires no permanent install. MIT license compatible with GPL-3.0. The `@redocly/openapi-core` package is already present as a transitive dependency of `openapi-typescript` in `node_modules`.
|
||||||
|
|
||||||
|
### Spectral (`@stoplight/spectral-cli`)
|
||||||
|
|
||||||
|
| Attribute | Value |
|
||||||
|
|---|---|
|
||||||
|
| OpenAPI 3.1 | Full support (since v6.x) |
|
||||||
|
| Install | `npm install -g @stoplight/spectral-cli` |
|
||||||
|
| CLI | `spectral lint api.yaml` |
|
||||||
|
| License | Apache 2.0 |
|
||||||
|
| Maintenance | Active — latest v6.15.0 (2025-04-22), slower cadence |
|
||||||
|
| GitHub | ~3k stars |
|
||||||
|
|
||||||
|
**Checks:** Schema compliance, missing descriptions/tags/operationIds, contact/license metadata. Highly extensible custom rulesets via YAML/JS. Configurable severity levels.
|
||||||
|
|
||||||
|
**Fit for this project:** Well-established industry standard. Apache 2.0 compatible with GPL. Less actively maintained than Redocly (10 months since last release). Heavier custom ruleset system may be over-engineered for current needs.
|
||||||
|
|
||||||
|
### Vacuum (`daveshanley/vacuum`)
|
||||||
|
|
||||||
|
| Attribute | Value |
|
||||||
|
|---|---|
|
||||||
|
| OpenAPI 3.1 | Full support (via libopenapi) |
|
||||||
|
| Install | `brew install daveshanley/vacuum/vacuum` or Go binary |
|
||||||
|
| CLI | `vacuum lint api.yaml` |
|
||||||
|
| License | MIT |
|
||||||
|
| Maintenance | Active — latest release 2025-12-22 |
|
||||||
|
| GitHub | ~1k stars |
|
||||||
|
|
||||||
|
**Checks:** Structural validation, Spectral-compatible rulesets, OWASP security checks, naming conventions, descriptions/examples/tags. Single Go binary — no runtime dependencies.
|
||||||
|
|
||||||
|
**Fit for this project:** Zero-dependency binary is appealing for CI. However, adds a non-Node.js tool dependency when the project already has Node.js. Spectral ruleset compatibility is a plus for portability.
|
||||||
|
|
||||||
|
### oasdiff (`oasdiff/oasdiff`)
|
||||||
|
|
||||||
|
| Attribute | Value |
|
||||||
|
|---|---|
|
||||||
|
| OpenAPI 3.1 | Beta |
|
||||||
|
| Install | `brew install oasdiff` or Go binary |
|
||||||
|
| CLI | `oasdiff breaking base.yaml revision.yaml` |
|
||||||
|
| License | Apache 2.0 |
|
||||||
|
| Maintenance | Active — latest v1.11.10 (2026-02-05) |
|
||||||
|
| GitHub | ~1.1k stars |
|
||||||
|
|
||||||
|
**Checks:** 300+ breaking change detection rules (paths, parameters, schemas, security, headers, enums). Requires two spec versions to compare — not a standalone validator.
|
||||||
|
|
||||||
|
**Fit for this project:** Different category — detects breaking changes between spec versions, not structural validity. Useful as a CI-only check comparing `HEAD~1` vs `HEAD`. OAS 3.1 support is still beta.
|
||||||
|
|
||||||
|
### Not Recommended
|
||||||
|
|
||||||
|
- **swagger-cli:** Abandoned, no OAS 3.1 support
|
||||||
|
- **IBM OpenAPI Validator:** Active but opinionated IBM-specific rules add configuration overhead for no benefit
|
||||||
|
|
||||||
|
## Tool Comparison Matrix
|
||||||
|
|
||||||
|
| Tool | OAS 3.1 | License | Last Release | Stars | Runtime | Category |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Redocly CLI** | Full | MIT | 2026-03-03 | ~1.4k | Node.js | Lint + validate |
|
||||||
|
| **Spectral** | Full | Apache 2.0 | 2025-04-22 | ~3k | Node.js | Lint |
|
||||||
|
| **Vacuum** | Full | MIT | 2025-12-22 | ~1k | Go binary | Lint + validate |
|
||||||
|
| **oasdiff** | Beta | Apache 2.0 | 2026-02-05 | ~1.1k | Go binary | Breaking changes |
|
||||||
|
|
||||||
|
## Integration Pattern
|
||||||
|
|
||||||
|
### Hook Script Structure
|
||||||
|
|
||||||
|
An OpenAPI validation hook would follow the existing pattern in `.claude/hooks/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INPUT=$(cat)
|
||||||
|
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Only run for OpenAPI spec files
|
||||||
|
case "$FILE_PATH" in
|
||||||
|
*/openapi/*.yaml|*/openapi/*.yml) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cd "$CLAUDE_PROJECT_DIR/backend"
|
||||||
|
|
||||||
|
# Run validation
|
||||||
|
if OUTPUT=$(npx @redocly/cli@latest lint src/main/resources/openapi/api.yaml --format=stylish 2>&1); then
|
||||||
|
echo '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"✓ OpenAPI spec validation passed."}}'
|
||||||
|
else
|
||||||
|
ESCAPED=$(echo "$OUTPUT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||||
|
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":$ESCAPED}}"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registration in `.claude/settings.json`
|
||||||
|
|
||||||
|
The hook would be added to the existing `PostToolUse` array alongside the compile and lint hooks:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/openapi-validate.sh\"",
|
||||||
|
"timeout": 120
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration (Optional)
|
||||||
|
|
||||||
|
A `redocly.yaml` in the project root or `backend/` directory can customize rules:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
extends:
|
||||||
|
- recommended
|
||||||
|
|
||||||
|
rules:
|
||||||
|
operation-operationId: error
|
||||||
|
tag-description: warn
|
||||||
|
no-ambiguous-paths: error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `.claude/settings.json:1-32` — Hook configuration (PostToolUse + Stop events)
|
||||||
|
- `.claude/hooks/backend-compile-check.sh` — Java file detection pattern + compile check
|
||||||
|
- `.claude/hooks/frontend-check.sh` — TS/Vue file detection pattern + type-check + lint
|
||||||
|
- `.claude/hooks/run-tests.sh` — Stop hook with test execution and block/approve logic
|
||||||
|
- `backend/pom.xml:149-178` — openapi-generator-maven-plugin configuration
|
||||||
|
- `backend/src/main/resources/openapi/api.yaml` — The OpenAPI 3.1.0 spec to validate
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should the validation use a pinned version (`npx @redocly/cli@1.x.x`) or latest? Pinned is more reproducible; latest gets rule updates automatically.
|
||||||
|
- Should a `redocly.yaml` config be added immediately with the `recommended` ruleset, or start with zero-config (structural validation only) and add rules incrementally?
|
||||||
|
- Is breaking change detection (oasdiff) desirable as a separate CI check, or is structural validation sufficient for now?
|
||||||
202
docs/agents/research/2026-03-04-rfc9457-problem-details.md
Normal file
202
docs/agents/research/2026-03-04-rfc9457-problem-details.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
date: 2026-03-04T21:15:50+00:00
|
||||||
|
git_commit: b8421274b47c6d1778b83c6b0acb70fd82891e71
|
||||||
|
branch: master
|
||||||
|
topic: "RFC 9457 Problem Details for HTTP API Error Responses"
|
||||||
|
tags: [research, error-handling, rfc9457, spring-boot, openapi]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: RFC 9457 Problem Details
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
|
||||||
|
How should the fete API structure error responses? What does RFC 9457 (Problem Details) specify, and how does it integrate with Spring Boot 3.5.x, OpenAPI 3.1, and openapi-fetch?
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
RFC 9457 (successor to RFC 7807) defines a standard JSON format (`application/problem+json`) for machine-readable HTTP API errors. Spring Boot 3.x has first-class support via `ProblemDetail`, `ErrorResponseException`, and `ResponseEntityExceptionHandler`. The recommended approach is a single `@RestControllerAdvice` that handles all exceptions consistently — no `spring.mvc.problemdetails.enabled` property, no fallback to legacy error format.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### RFC 9457 Format
|
||||||
|
|
||||||
|
Standard fields:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `type` | URI | Identifies the problem type. Defaults to `about:blank`. |
|
||||||
|
| `title` | string | Short, human-readable summary. Should not change between occurrences. |
|
||||||
|
| `status` | int | HTTP status code. |
|
||||||
|
| `detail` | string | Human-readable explanation specific to this occurrence. |
|
||||||
|
| `instance` | URI | Identifies the specific occurrence (e.g. correlation ID). |
|
||||||
|
|
||||||
|
Extension members (additional JSON properties) are explicitly permitted. This is the mechanism for validation errors, error codes, etc.
|
||||||
|
|
||||||
|
**Key rule:** With `type: "about:blank"`, the `title` must match the HTTP status phrase exactly. Use a custom `type` URI when providing a custom `title`.
|
||||||
|
|
||||||
|
### Spring Boot 3.x Built-in Support
|
||||||
|
|
||||||
|
- **`ProblemDetail`** — container class for the five standard fields + a `properties` Map for extensions.
|
||||||
|
- **`ErrorResponseException`** — base class for custom exceptions that carry their own `ProblemDetail`.
|
||||||
|
- **`ResponseEntityExceptionHandler`** — `@ControllerAdvice` base class that handles all Spring MVC exceptions and renders them as `application/problem+json`.
|
||||||
|
- **`ProblemDetailJacksonMixin`** — automatically unwraps the `properties` Map as top-level JSON fields during serialization.
|
||||||
|
|
||||||
|
### Recommended Configuration
|
||||||
|
|
||||||
|
Use a single `@RestControllerAdvice` extending `ResponseEntityExceptionHandler`. Do **not** use the `spring.mvc.problemdetails.enabled` property.
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||||
|
// All Spring MVC exceptions are handled automatically.
|
||||||
|
// Add @ExceptionHandler methods for domain exceptions here.
|
||||||
|
// Add a catch-all for Exception.class to prevent legacy error format.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reasons to avoid the property-based approach:
|
||||||
|
1. No place to add custom `@ExceptionHandler` methods.
|
||||||
|
2. Having both the property AND a custom `ResponseEntityExceptionHandler` bean causes a conflict.
|
||||||
|
3. The property ignores `server.error.include-*` properties.
|
||||||
|
|
||||||
|
### Validation Errors (Field-Level)
|
||||||
|
|
||||||
|
Spring deliberately does **not** include field-level validation errors in `ProblemDetail` by default (security rationale). Override `handleMethodArgumentNotValid`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||||
|
MethodArgumentNotValidException ex,
|
||||||
|
HttpHeaders headers,
|
||||||
|
HttpStatusCode status,
|
||||||
|
WebRequest request) {
|
||||||
|
|
||||||
|
ProblemDetail problemDetail = ex.getBody();
|
||||||
|
problemDetail.setTitle("Validation Failed");
|
||||||
|
problemDetail.setType(URI.create("urn:problem-type:validation-error"));
|
||||||
|
|
||||||
|
List<Map<String, String>> fieldErrors = ex.getBindingResult()
|
||||||
|
.getFieldErrors()
|
||||||
|
.stream()
|
||||||
|
.map(fe -> Map.of(
|
||||||
|
"field", fe.getField(),
|
||||||
|
"message", fe.getDefaultMessage()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
problemDetail.setProperty("fieldErrors", fieldErrors);
|
||||||
|
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Resulting response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "urn:problem-type:validation-error",
|
||||||
|
"title": "Validation Failed",
|
||||||
|
"status": 400,
|
||||||
|
"detail": "Invalid request content.",
|
||||||
|
"instance": "/api/events",
|
||||||
|
"fieldErrors": [
|
||||||
|
{ "field": "title", "message": "must not be blank" },
|
||||||
|
{ "field": "expiryDate", "message": "must be a future date" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenAPI Schema Definition
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ProblemDetail:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
default: "about:blank"
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
detail:
|
||||||
|
type: string
|
||||||
|
instance:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
ValidationProblemDetail:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProblemDetail'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
fieldErrors:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- field
|
||||||
|
- message
|
||||||
|
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: Validation failed
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ValidationProblemDetail'
|
||||||
|
NotFound:
|
||||||
|
description: Resource not found
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProblemDetail'
|
||||||
|
```
|
||||||
|
|
||||||
|
Use media type `application/problem+json` in response definitions. Set `additionalProperties: true` on the base schema.
|
||||||
|
|
||||||
|
### Frontend Consumption (openapi-fetch)
|
||||||
|
|
||||||
|
openapi-fetch uses a discriminated union for responses:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { data, error } = await client.POST('/api/events', { body: eventData })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// `error` is typed from the OpenAPI error response schema
|
||||||
|
console.log(error.title) // "Validation Failed"
|
||||||
|
console.log(error.fieldErrors) // [{ field: "title", message: "..." }]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// `data` is the typed success response
|
||||||
|
```
|
||||||
|
|
||||||
|
The `error` object is already typed from the generated schema — no manual type assertions needed for defined error shapes.
|
||||||
|
|
||||||
|
### Known Pitfalls
|
||||||
|
|
||||||
|
| Pitfall | Description | Mitigation |
|
||||||
|
|---------|-------------|------------|
|
||||||
|
| **Inconsistent formats** | Exceptions escaping to Spring Boot's `BasicErrorController` return legacy format (`timestamp`, `error`, `path`), not Problem Details. | Add a catch-all `@ExceptionHandler(Exception.class)` in the `@RestControllerAdvice`. |
|
||||||
|
| **`server.error.include-*` ignored** | When Problem Details is active, these properties have no effect. | Control content via `ProblemDetail` directly. |
|
||||||
|
| **Validation errors hidden by default** | Spring returns only `"Invalid request content."` without field details. | Override `handleMethodArgumentNotValid` explicitly. |
|
||||||
|
| **Content negotiation** | `application/problem+json` is only returned when the client accepts it. `openapi-fetch` sends `Accept: application/json` which Spring considers compatible. | No action needed for SPA clients. |
|
||||||
|
| **`about:blank` semantics** | With `type: "about:blank"`, `title` must match the HTTP status phrase. Custom titles require a custom `type` URI. | Use `urn:problem-type:*` URIs for custom problem types. |
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [RFC 9457 Full Text](https://www.rfc-editor.org/rfc/rfc9457.html)
|
||||||
|
- [Spring Framework Docs: Error Responses](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html)
|
||||||
|
- [Swagger Blog: Problem Details RFC 9457](https://swagger.io/blog/problem-details-rfc9457-doing-api-errors-well/)
|
||||||
|
- [Baeldung: Returning Errors Using ProblemDetail](https://www.baeldung.com/spring-boot-return-errors-problemdetail)
|
||||||
|
- [SivaLabs: Spring Boot 3 Error Reporting](https://www.sivalabs.in/blog/spring-boot-3-error-reporting-using-problem-details/)
|
||||||
|
- [Spring Boot Issue #43850: Render global errors as Problem Details](https://github.com/spring-projects/spring-boot/issues/43850)
|
||||||
404
docs/agents/research/2026-03-04-sans-serif-fonts.md
Normal file
404
docs/agents/research/2026-03-04-sans-serif-fonts.md
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
# Research: Modern Sans-Serif Fonts for Mobile-First PWA
|
||||||
|
|
||||||
|
**Date:** 2026-03-04
|
||||||
|
**Context:** Selecting a primary typeface for fete, a privacy-focused PWA for event announcements and RSVPs. The font must be open-source with permissive licensing, modern geometric/neo-grotesque style, excellent mobile readability, and strong weight range.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Based on research of 9 candidate fonts, **6 meet all requirements** for self-hosting and redistribution under permissive licenses. Two do not qualify:
|
||||||
|
|
||||||
|
- **General Sans**: Proprietary (ITF Free Font License, non-commercial personal use only)
|
||||||
|
- **Satoshi**: License ambiguity; sources conflict between full OFL and ITF restrictions
|
||||||
|
|
||||||
|
The remaining **6 fonts are fully open-source** and suitable for the project:
|
||||||
|
|
||||||
|
| Font | License | Design | Weights | Status |
|
||||||
|
|------|---------|--------|---------|--------|
|
||||||
|
| Inter | OFL-1.1 | Neo-grotesque, humanist | 9 (Thin–Black) | ✅ Recommended |
|
||||||
|
| Plus Jakarta Sans | OFL-1.1 | Geometric, modern | 7 (ExtraLight–ExtraBold) | ✅ Recommended |
|
||||||
|
| Outfit | OFL-1.1 | Geometric | 9 (Thin–Black) | ✅ Recommended |
|
||||||
|
| Space Grotesk | OFL-1.1 | Neo-grotesque, distinctive | 5 (Light–Bold) | ✅ Recommended |
|
||||||
|
| Manrope | OFL-1.1 | Geometric, humanist | 7 (ExtraLight–ExtraBold) | ✅ Recommended |
|
||||||
|
| DM Sans | OFL-1.1 | Geometric, low-contrast | 9 (Thin–Black) | ✅ Recommended |
|
||||||
|
| Sora | OFL-1.1 | Geometric | 8 (Thin–ExtraBold) | ✅ Recommended |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Candidate Analysis
|
||||||
|
|
||||||
|
### 1. Inter
|
||||||
|
|
||||||
|
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||||
|
|
||||||
|
**Download Location:**
|
||||||
|
- **Official:** https://github.com/rsms/inter (releases page)
|
||||||
|
- **NPM:** `inter-ui` package
|
||||||
|
- **Homebrew:** `font-inter`
|
||||||
|
- **Official CDN:** https://rsms.me/inter/inter.css
|
||||||
|
|
||||||
|
**Design Character:** Neo-grotesque with humanist touches. High x-height for enhanced legibility on screens. Geometric letterforms with open apertures. Designed specifically for UI and on-screen use.
|
||||||
|
|
||||||
|
**Available Weights:** 9 weights from Thin (100) to Black (900), each with italic variant. Also available as a variable font with weight axis.
|
||||||
|
|
||||||
|
**Notable Apps/Products:**
|
||||||
|
- **UX/Design tools:** Figma, Notion, Pixar Presto
|
||||||
|
- **OS:** Elementary OS, GNOME
|
||||||
|
- **Web:** GitLab, ISO, Mozilla, NASA
|
||||||
|
- **Why:** Chosen by product teams valuing clarity and modern minimalism; default choice for UI designers
|
||||||
|
|
||||||
|
**Mobile Suitability:** Excellent. Specifically engineered for screen readability with high x-height and open apertures. Performs well at 14–16px body text.
|
||||||
|
|
||||||
|
**Distinctive Strengths:**
|
||||||
|
- Purpose-built for digital interfaces
|
||||||
|
- Exceptional clarity in dense UI layouts
|
||||||
|
- Strong brand identity (recognizable across tech products)
|
||||||
|
- Extensive OpenType features
|
||||||
|
|
||||||
|
**Weakness:** Very widely used; less distinctive for a bold brand identity. Considered the "safe" choice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Plus Jakarta Sans
|
||||||
|
|
||||||
|
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||||
|
|
||||||
|
**Download Location:**
|
||||||
|
- **Official Repository:** https://github.com/tokotype/PlusJakartaSans
|
||||||
|
- **Source Files:** `sources/`, compiled fonts in `fonts/` directory
|
||||||
|
- **Designer Contact:** mail@tokotype.com (Gumpita Rahayu, Tokotype)
|
||||||
|
- **Latest Version:** 2.7.1 (May 2023)
|
||||||
|
- **Build Command:** `gftools builder sources/builder.yaml`
|
||||||
|
|
||||||
|
**Design Character:** Geometric sans-serif with modern, clean-cut forms. Inspired by Neuzeit Grotesk and Futura but with contemporary refinement. Slightly taller x-height for clear spacing between caps and lowercase. Open counters and balanced spacing for legibility across sizes. **Bold, distinctive look** with personality.
|
||||||
|
|
||||||
|
**Available Weights:** 7 weights from ExtraLight (200) to ExtraBold (800), with matching italics.
|
||||||
|
|
||||||
|
**Notable Apps/Products:**
|
||||||
|
- Original commission: Jakarta Provincial Government's "+Jakarta City of Collaboration" program (2020)
|
||||||
|
- Now widely used in: Branding projects, modern web design, UI design
|
||||||
|
- **Why:** Chosen for fresh, contemporary feel without generic blandness
|
||||||
|
|
||||||
|
**Mobile Suitability:** Excellent. Designed with mobile UI in mind. Clean letterforms render crisply on small screens.
|
||||||
|
|
||||||
|
**Distinctive Strengths:**
|
||||||
|
- **Stylistic sets:** Sharp, Straight, and Swirl variants add design flexibility
|
||||||
|
- Modern geometric with Indonesian design heritage (unique perspective)
|
||||||
|
- Excellent for branding (not generic like Inter)
|
||||||
|
- OpenType features for sophisticated typography
|
||||||
|
- Well-maintained, active development
|
||||||
|
|
||||||
|
**Weakness:** Less ubiquitous than Inter; smaller ecosystem of design tool integrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Outfit
|
||||||
|
|
||||||
|
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||||
|
|
||||||
|
**Download Location:**
|
||||||
|
- **Official Repository:** https://github.com/Outfitio/Outfit-Fonts
|
||||||
|
- **Fonts Directory:** `/fonts` in repository
|
||||||
|
- **OFL Text:** `OFL.txt` in repository
|
||||||
|
- **Designer:** Rodrigo Fuenzalida (originally for Outfit.io)
|
||||||
|
- **Status:** Repository archived Feb 25, 2025 (read-only, downloads remain accessible)
|
||||||
|
|
||||||
|
**Design Character:** Geometric sans-serif with warm, friendly appearance. Generous x-height, balanced spacing, low contrast. Nine static weights plus variable font with weight axis.
|
||||||
|
|
||||||
|
**Available Weights:** 9 weights from Thin (100) to Black (900). No italics.
|
||||||
|
|
||||||
|
**Notable Apps/Products:**
|
||||||
|
- Originally created for Outfit.io platform
|
||||||
|
- Good readability for body text (≈16px) and strong headline presence
|
||||||
|
- Used in design tools (Figma integration)
|
||||||
|
|
||||||
|
**Mobile Suitability:** Good. Geometric forms and generous spacing work well on mobile, though low contrast may require careful pairing with sufficient color contrast.
|
||||||
|
|
||||||
|
**Distinctive Strengths:**
|
||||||
|
- Full weight range (Thin–Black)
|
||||||
|
- Variable font option for granular weight control
|
||||||
|
- Stylistic alternates and rare ligatures
|
||||||
|
- Accessible character set
|
||||||
|
|
||||||
|
**Weakness:** Archived repository; no active development. Low contrast design requires careful color/contrast pairing for accessibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Space Grotesk
|
||||||
|
|
||||||
|
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||||
|
|
||||||
|
**Download Location:**
|
||||||
|
- **Official Repository:** https://github.com/floriankarsten/space-grotesk
|
||||||
|
- **Official Site:** https://fonts.floriankarsten.com/space-grotesk
|
||||||
|
- **Designer:** Florian Karsten
|
||||||
|
- **Variants:** Variable font with weight axis
|
||||||
|
|
||||||
|
**Design Character:** Neo-grotesque with distinctive personality. Proportional variant of Space Mono (Colophon Foundry, 2016). Retains Space Mono's idiosyncratic details while optimizing for improved readability. Bold, tech-forward aesthetic with monowidth heritage visible in character design.
|
||||||
|
|
||||||
|
**Available Weights:** 5 weights—Light (300), Regular (400), Medium (500), SemiBold (600), Bold (700). No italics.
|
||||||
|
|
||||||
|
**Notable Apps/Products:**
|
||||||
|
- Modern tech companies and startups seeking distinctive branding
|
||||||
|
- Popular in neo-brutalist web design
|
||||||
|
- Good for headlines and display use
|
||||||
|
|
||||||
|
**Mobile Suitability:** Good. Clean proportional forms with distinctive character. Works well for headlines; body text at 14px+ is readable.
|
||||||
|
|
||||||
|
**Distinctive Strengths:**
|
||||||
|
- **Bold, tech-forward personality** — immediately recognizable
|
||||||
|
- Heritage from Space Mono adds character without looking dated
|
||||||
|
- Excellent OpenType support (old-style figures, tabular figures, superscript, subscript, fractions, stylistic alternates)
|
||||||
|
- **Supports extended language coverage:** Latin, Vietnamese, Pinyin, Central/South-Eastern European
|
||||||
|
|
||||||
|
**Weakness:** Only 5 weights (lightest is 300, no Thin). Fewer weight options than Inter or DM Sans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Manrope
|
||||||
|
|
||||||
|
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||||
|
|
||||||
|
**Download Location:**
|
||||||
|
- **Official Repository:** https://github.com/sharanda/manrope
|
||||||
|
- **Designer:** Mikhail Sharanda (2018), converted to variable by Mirko Velimirovic (2019)
|
||||||
|
- **Alternative Sources:** Multiple community forks on GitHub, npm packages
|
||||||
|
- **NPM Package:** `@fontsource/manrope`, `@fontsource-variable/manrope`
|
||||||
|
|
||||||
|
**Design Character:** Modern geometric sans-serif blending geometric shapes with humanistic elements. Semi-condensed structure with clean, contemporary feel. Geometric digits, packed with OpenType features.
|
||||||
|
|
||||||
|
**Available Weights:** 7 weights from ExtraLight (200) to ExtraBold (800). Available as variable font.
|
||||||
|
|
||||||
|
**Notable Apps/Products:**
|
||||||
|
- Widely used in modern design systems
|
||||||
|
- Popular in product/SaaS design
|
||||||
|
- Good for both UI and branding
|
||||||
|
|
||||||
|
**Mobile Suitability:** Excellent. Clean geometric design with humanistic touches; balanced proportions work well on mobile.
|
||||||
|
|
||||||
|
**Distinctive Strengths:**
|
||||||
|
- Geometric + humanistic blend (best of both worlds)
|
||||||
|
- Well-maintained active project
|
||||||
|
- Variable font available
|
||||||
|
- Strong design community around the font
|
||||||
|
|
||||||
|
**Weakness:** None significant; solid all-around choice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. DM Sans
|
||||||
|
|
||||||
|
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||||
|
|
||||||
|
**Download Location:**
|
||||||
|
- **Official Repository:** https://github.com/googlefonts/dm-fonts
|
||||||
|
- **Releases Page:** https://github.com/googlefonts/dm-fonts/releases
|
||||||
|
- **Google Fonts:** https://fonts.google.com/specimen/DM+Sans
|
||||||
|
- **Design:** Commissioned from Colophon Foundry; Creative Direction: MultiAdaptor & DeepMind
|
||||||
|
|
||||||
|
**Design Character:** Low-contrast geometric sans-serif optimized for text at smaller sizes. Part of the DM suite (DM Sans, DM Serif Text, DM Serif Display). Designed for clarity and efficiency in dense typography.
|
||||||
|
|
||||||
|
**Available Weights:** 9 weights from Thin (100) to Black (900), each with italic variant.
|
||||||
|
|
||||||
|
**Notable Apps/Products:**
|
||||||
|
- DeepMind products (by commission)
|
||||||
|
- Tech companies favoring geometric clarity
|
||||||
|
- Professional and commercial products requiring text legibility
|
||||||
|
|
||||||
|
**Mobile Suitability:** Excellent. Specifically optimized for small text sizes; low contrast minimizes visual noise on mobile screens.
|
||||||
|
|
||||||
|
**Distinctive Strengths:**
|
||||||
|
- **Optimized for small text** — superior at 12–14px
|
||||||
|
- Full weight range (Thin–Black)
|
||||||
|
- Active Google Fonts maintenance
|
||||||
|
- Italic variants (unlike Outfit or Space Grotesk)
|
||||||
|
- Commissioned by reputable team (DeepMind)
|
||||||
|
|
||||||
|
**Weakness:** Low contrast may feel less bold on headlines without careful sizing/weight adjustment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Sora
|
||||||
|
|
||||||
|
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||||
|
|
||||||
|
**Download Location:**
|
||||||
|
- **Official Repository:** https://github.com/sora-xor/sora-font
|
||||||
|
- **GitHub Releases:** Direct TTF/OTF downloads available
|
||||||
|
- **NPM Packages:** `@fontsource/sora`, `@fontsource-variable/sora`
|
||||||
|
- **Original Purpose:** Custom typeface for SORA decentralized autonomous economy
|
||||||
|
|
||||||
|
**Design Character:** Geometric sans-serif with contemporary, clean aesthetic. Available as both static fonts and variable font. Designed as a branding solution for decentralized systems.
|
||||||
|
|
||||||
|
**Available Weights:** 8 weights from Thin (100) to ExtraBold (800), each with italic variant. Variable font available.
|
||||||
|
|
||||||
|
**Notable Apps/Products:**
|
||||||
|
- Sora (XOR) decentralized projects
|
||||||
|
- Crypto/blockchain projects using modern typography
|
||||||
|
- Web3 products seeking distinctive branding
|
||||||
|
|
||||||
|
**Mobile Suitability:** Good. Clean geometric forms render well on mobile; italics available for emphasis.
|
||||||
|
|
||||||
|
**Distinctive Strengths:**
|
||||||
|
- Full weight range with italics
|
||||||
|
- Variable font option
|
||||||
|
- Designed for digital-first branding
|
||||||
|
- GitHub-native distribution
|
||||||
|
|
||||||
|
**Weakness:** Less established than Inter or DM Sans in mainstream product design; smaller ecosystem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rejected Candidates
|
||||||
|
|
||||||
|
### General Sans
|
||||||
|
|
||||||
|
**Status:** ❌ Does not meet licensing requirements
|
||||||
|
|
||||||
|
**License:** ITF Free Font License (proprietary, non-commercial personal use only)
|
||||||
|
|
||||||
|
**Why Rejected:** This is a **paid commercial font** distributed by the Indian Type Foundry (not open-source). The ITF Free Font License permits personal use only; commercial use requires a separate paid license. Does not meet the "open-source with permissive license" requirement.
|
||||||
|
|
||||||
|
**Designer:** Frode Helland (published by Indian Type Foundry)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Satoshi
|
||||||
|
|
||||||
|
**Status:** ⚠️ License ambiguity — conflicting sources
|
||||||
|
|
||||||
|
**Documented License:**
|
||||||
|
- Some sources claim SIL Open Font License (OFL-1.1)
|
||||||
|
- Other sources indicate ITF Free Font License (personal use only) similar to General Sans
|
||||||
|
|
||||||
|
**Design:** Swiss-style modernist sans-serif (Light to Black, 5–10 weights)
|
||||||
|
|
||||||
|
**Download:** Fontshare (Indian Type Foundry's free font service)
|
||||||
|
|
||||||
|
**Why Not Recommended:** The license status is unclear. While Fontshare advertises "free for personal and commercial use," the font's origin (Indian Type Foundry) and conflicting license documentation create uncertainty. For a privacy-focused project with clear open-source requirements, Satoshi's ambiguous licensing creates unnecessary legal risk. Better alternatives with unambiguous OFL-1.1 licensing are available.
|
||||||
|
|
||||||
|
**Recommendation:** If clarity is needed, contact Fontshare/ITF directly. For now, exclude from consideration to reduce licensing complexity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparative Table: Qualified Fonts
|
||||||
|
|
||||||
|
| Metric | Inter | Plus Jakarta Sans | Outfit | Space Grotesk | Manrope | DM Sans | Sora |
|
||||||
|
|--------|-------|-------------------|--------|---------------|---------|---------|------|
|
||||||
|
| **License** | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 |
|
||||||
|
| **Weights** | 9 | 7 | 9 | 5 | 7 | 9 | 8 |
|
||||||
|
| **Italics** | ✅ Yes | ✅ Yes | ❌ No | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
|
||||||
|
| **Variable Font** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
|
||||||
|
| **Design** | Neo-grotesque | Geometric | Geometric | Neo-grotesque | Geo + Humanist | Geometric | Geometric |
|
||||||
|
| **Personality** | Generic/Safe | Bold/Fresh | Warm/Friendly | Tech-Forward | Balanced | Efficient/Clean | Contemporary |
|
||||||
|
| **Mobile Text** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||||
|
| **Distinctiveness** | Low | High | Medium | High | High | Medium | Medium |
|
||||||
|
| **Ecosystem** | Very Large | Growing | Medium | Growing | Growing | Large | Small |
|
||||||
|
| **Active Dev** | ✅ Yes | ✅ Yes | ❌ Archived | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### For Bold App-Native Branding
|
||||||
|
|
||||||
|
**Primary Choice: Plus Jakarta Sans**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Fully open-source (OFL-1.1) with unambiguous licensing
|
||||||
|
- Bold, modern geometric aesthetic suitable for app branding
|
||||||
|
- Stylistic sets (Sharp, Straight, Swirl) provide design flexibility
|
||||||
|
- Well-maintained by Tokotype with clear development history
|
||||||
|
- Strong presence in modern UI/web design
|
||||||
|
- Excellent mobile readability with thoughtful character spacing
|
||||||
|
- Indonesian design heritage adds unique perspective (not generic)
|
||||||
|
|
||||||
|
**Alternative: Space Grotesk**
|
||||||
|
|
||||||
|
If you prefer **even more distinctive character:**
|
||||||
|
- Neo-grotesque with tech-forward personality
|
||||||
|
- Smaller weight range (5 weights) but strong identity
|
||||||
|
- Popular in contemporary design circles
|
||||||
|
- Good for headlines; pair with a more neutral font for body text if needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### For Safe, Professional UI
|
||||||
|
|
||||||
|
**Primary Choice: Inter or DM Sans**
|
||||||
|
|
||||||
|
**Inter if:**
|
||||||
|
- Maximum ecosystem and tool support desired
|
||||||
|
- Designing for broad recognition and trust
|
||||||
|
- Team already familiar with Inter (widespread in tech)
|
||||||
|
|
||||||
|
**DM Sans if:**
|
||||||
|
- Emphasis on small text legibility (optimized for 12–14px)
|
||||||
|
- Prefer italic variants
|
||||||
|
- Want active maintenance from Google Fonts community
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### For Balanced Approach
|
||||||
|
|
||||||
|
**Manrope**
|
||||||
|
|
||||||
|
- Geometric + humanistic blend (versatile)
|
||||||
|
- Excellent mobile performance
|
||||||
|
- Strong weight range (7 weights)
|
||||||
|
- Underrated choice; often overlooked for bolder options but delivers polish
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes for Self-Hosting
|
||||||
|
|
||||||
|
All recommended fonts can be self-hosted:
|
||||||
|
|
||||||
|
1. **Download:** Clone repository or download from releases page
|
||||||
|
2. **Generate Web Formats:** Use FontForge, FontTools, or online converters to generate WOFF2 (required for modern browsers)
|
||||||
|
3. **CSS:** Include via `@font-face` with local file paths
|
||||||
|
4. **License:** Include `LICENSE.txt` or `OFL.txt` in the distribution
|
||||||
|
|
||||||
|
Example self-hosted CSS:
|
||||||
|
```css
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
src: url('/fonts/PlusJakartaSans-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy Considerations
|
||||||
|
|
||||||
|
All selected fonts are self-hosted open-source projects with no telemetry, no external CDN dependencies, and no tracking. Fully compliant with the project's privacy-first principles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**Inter, Plus Jakarta Sans, and Space Grotesk** are the strongest candidates. The choice depends on brand positioning:
|
||||||
|
|
||||||
|
- **Generic + Safe → Inter**
|
||||||
|
- **Bold + Modern → Plus Jakarta Sans**
|
||||||
|
- **Tech-Forward + Distinctive → Space Grotesk**
|
||||||
|
|
||||||
|
All seven recommended fonts meet the strict licensing, openness, mobile readability, and weight-range requirements. Any of them are viable; the decision is primarily aesthetic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [Inter Font GitHub Repository](https://github.com/rsms/inter)
|
||||||
|
- [Plus Jakarta Sans GitHub Repository](https://github.com/tokotype/PlusJakartaSans)
|
||||||
|
- [Outfit Fonts GitHub Repository](https://github.com/Outfitio/Outfit-Fonts)
|
||||||
|
- [Space Grotesk GitHub Repository](https://github.com/floriankarsten/space-grotesk)
|
||||||
|
- [Manrope GitHub Repository](https://github.com/sharanda/manrope)
|
||||||
|
- [DM Fonts GitHub Repository](https://github.com/googlefonts/dm-fonts)
|
||||||
|
- [Sora Font GitHub Repository](https://github.com/sora-xor/sora-font)
|
||||||
|
- [SIL Open Font License](https://openfontlicense.org/)
|
||||||
|
- [Google Fonts (reference)](https://fonts.google.com)
|
||||||
|
- [Fontshare (reference)](https://www.fontshare.com)
|
||||||
@@ -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/)
|
||||||
213
docs/agents/research/2026-03-04-t3-cicd-pipeline.md
Normal file
213
docs/agents/research/2026-03-04-t3-cicd-pipeline.md
Normal 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.
|
||||||
359
docs/agents/research/2026-03-04-t4-development-infrastructure.md
Normal file
359
docs/agents/research/2026-03-04-t4-development-infrastructure.md
Normal 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.
|
||||||
195
docs/agents/research/2026-03-04-us1-create-event.md
Normal file
195
docs/agents/research/2026-03-04-us1-create-event.md
Normal 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).
|
||||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -37,3 +37,4 @@ __screenshots__/
|
|||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
*.timestamp-*-*.mjs
|
*.timestamp-*-*.mjs
|
||||||
|
.rodney/
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<title>fete</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,85 +1,9 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<div class="app-container">
|
||||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
<RouterView />
|
||||||
|
</div>
|
||||||
<div class="wrapper">
|
|
||||||
<HelloWorld msg="You did it!" />
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
<RouterLink to="/">Home</RouterLink>
|
|
||||||
<RouterLink to="/about">About</RouterLink>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<RouterView />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup lang="ts">
|
||||||
header {
|
import { RouterView } from 'vue-router'
|
||||||
line-height: 1.5;
|
</script>
|
||||||
max-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a:first-of-type {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
padding-right: calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 2rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header .wrapper {
|
|
||||||
display: flex;
|
|
||||||
place-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
text-align: left;
|
|
||||||
margin-left: -1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
padding: 1rem 0;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
/* color palette from <https://github.com/vuejs/theme> */
|
|
||||||
:root {
|
|
||||||
--vt-c-white: #ffffff;
|
|
||||||
--vt-c-white-soft: #f8f8f8;
|
|
||||||
--vt-c-white-mute: #f2f2f2;
|
|
||||||
|
|
||||||
--vt-c-black: #181818;
|
|
||||||
--vt-c-black-soft: #222222;
|
|
||||||
--vt-c-black-mute: #282828;
|
|
||||||
|
|
||||||
--vt-c-indigo: #2c3e50;
|
|
||||||
|
|
||||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
|
||||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
|
||||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
|
||||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
|
||||||
|
|
||||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
|
||||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
|
||||||
--vt-c-text-dark-1: var(--vt-c-white);
|
|
||||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-white);
|
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-light-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-light-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
|
||||||
--color-text: var(--vt-c-text-light-1);
|
|
||||||
|
|
||||||
--section-gap: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-black);
|
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-dark-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
color: var(--color-text);
|
|
||||||
background: var(--color-background);
|
|
||||||
transition:
|
|
||||||
color 0.5s,
|
|
||||||
background-color 0.5s;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-family:
|
|
||||||
Inter,
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
Oxygen,
|
|
||||||
Ubuntu,
|
|
||||||
Cantarell,
|
|
||||||
'Fira Sans',
|
|
||||||
'Droid Sans',
|
|
||||||
'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
font-size: 15px;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
BIN
frontend/src/assets/fonts/Sora-Variable.woff2
Normal file
BIN
frontend/src/assets/fonts/Sora-Variable.woff2
Normal file
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,35 +1,181 @@
|
|||||||
@import './base.css';
|
@font-face {
|
||||||
|
font-family: 'Sora';
|
||||||
|
src: url('@/assets/fonts/Sora-Variable.woff2') format('woff2');
|
||||||
|
font-weight: 100 800;
|
||||||
|
font-display: swap;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Colors: Electric Dusk */
|
||||||
|
--color-gradient-start: #f06292;
|
||||||
|
--color-gradient-mid: #ab47bc;
|
||||||
|
--color-gradient-end: #5c6bc0;
|
||||||
|
--color-accent: #ff7043;
|
||||||
|
--color-text: #1c1c1e;
|
||||||
|
--color-text-on-gradient: #ffffff;
|
||||||
|
--color-surface: #fff5f8;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
|
||||||
|
/* Gradient */
|
||||||
|
--gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 0.5rem;
|
||||||
|
--spacing-sm: 0.75rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.2rem;
|
||||||
|
--spacing-xl: 1.5rem;
|
||||||
|
--spacing-2xl: 2rem;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--radius-card: 14px;
|
||||||
|
--radius-button: 14px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--content-max-width: 480px;
|
||||||
|
--content-padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'Sora', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
max-width: 1280px;
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
display: flex;
|
||||||
padding: 2rem;
|
flex-direction: column;
|
||||||
font-weight: normal;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
.app-container {
|
||||||
.green {
|
width: 100%;
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: var(--content-padding);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card-style form fields */
|
||||||
|
.form-field {
|
||||||
|
background: var(--color-card);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--spacing-md) var(--spacing-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field:focus {
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field::placeholder {
|
||||||
|
color: #999;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-field {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form group (label + input) */
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary action button */
|
||||||
|
.btn-primary {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-button);
|
||||||
|
transition: opacity 0.2s ease, transform 0.1s ease;
|
||||||
|
text-align: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: hsla(160, 100%, 37%, 1);
|
|
||||||
transition: 0.4s;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
.btn-primary:hover {
|
||||||
a:hover {
|
opacity: 0.92;
|
||||||
background-color: hsla(160, 100%, 37%, 0.2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
.btn-primary:active {
|
||||||
body {
|
transform: scale(0.98);
|
||||||
display: flex;
|
}
|
||||||
place-items: center;
|
|
||||||
}
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
#app {
|
cursor: not-allowed;
|
||||||
display: grid;
|
}
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
padding: 0 2rem;
|
/* Error message */
|
||||||
}
|
.field-error {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
msg: string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="greetings">
|
|
||||||
<h1 class="green">{{ msg }}</h1>
|
|
||||||
<h3>
|
|
||||||
You’ve successfully created a project with
|
|
||||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
h1 {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 2.6rem;
|
|
||||||
position: relative;
|
|
||||||
top: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import WelcomeItem from './WelcomeItem.vue'
|
|
||||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
|
||||||
import ToolingIcon from './icons/IconTooling.vue'
|
|
||||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
|
||||||
import CommunityIcon from './icons/IconCommunity.vue'
|
|
||||||
import SupportIcon from './icons/IconSupport.vue'
|
|
||||||
|
|
||||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<DocumentationIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Documentation</template>
|
|
||||||
|
|
||||||
Vue’s
|
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
|
||||||
provides you with all information you need to get started.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<ToolingIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Tooling</template>
|
|
||||||
|
|
||||||
This project is served and bundled with
|
|
||||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
|
||||||
recommended IDE setup is
|
|
||||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
|
||||||
+
|
|
||||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
|
||||||
>Vue - Official</a
|
|
||||||
>. If you need to test your components and web pages, check out
|
|
||||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
|
||||||
and
|
|
||||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
|
||||||
/
|
|
||||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
More instructions are available in
|
|
||||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
|
||||||
>.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<EcosystemIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Ecosystem</template>
|
|
||||||
|
|
||||||
Get official tools and libraries for your project:
|
|
||||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
|
||||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
|
||||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
|
||||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
|
||||||
you need more resources, we suggest paying
|
|
||||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
|
||||||
a visit.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<CommunityIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Community</template>
|
|
||||||
|
|
||||||
Got stuck? Ask your question on
|
|
||||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
|
||||||
(our official Discord server), or
|
|
||||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
|
||||||
>StackOverflow</a
|
|
||||||
>. You should also follow the official
|
|
||||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
|
||||||
Bluesky account or the
|
|
||||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
|
||||||
X account for latest news in the Vue world.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<SupportIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Support Vue</template>
|
|
||||||
|
|
||||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
|
||||||
us by
|
|
||||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
|
||||||
</WelcomeItem>
|
|
||||||
</template>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="item">
|
|
||||||
<i>
|
|
||||||
<slot name="icon"></slot>
|
|
||||||
</i>
|
|
||||||
<div class="details">
|
|
||||||
<h3>
|
|
||||||
<slot name="heading"></slot>
|
|
||||||
</h3>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.item {
|
|
||||||
margin-top: 2rem;
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
place-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
color: var(--color-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.item {
|
|
||||||
margin-top: 0;
|
|
||||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
top: calc(50% - 25px);
|
|
||||||
left: -26px;
|
|
||||||
position: absolute;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-background);
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:before {
|
|
||||||
content: ' ';
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: calc(50% + 25px);
|
|
||||||
height: calc(50% - 25px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:after {
|
|
||||||
content: ' ';
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: calc(50% + 25px);
|
|
||||||
height: calc(50% - 25px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:first-of-type:before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:last-of-type:after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import HelloWorld from '../HelloWorld.vue'
|
|
||||||
|
|
||||||
describe('HelloWorld', () => {
|
|
||||||
it('renders properly', () => {
|
|
||||||
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
|
|
||||||
expect(wrapper.text()).toContain('Hello Vitest')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
|
||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
aria-hidden="true"
|
|
||||||
role="img"
|
|
||||||
class="iconify iconify--mdi"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
|
||||||
fill="currentColor"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
119
frontend/src/composables/__tests__/useEventStorage.spec.ts
Normal file
119
frontend/src/composables/__tests__/useEventStorage.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { useEventStorage } from '../useEventStorage'
|
||||||
|
|
||||||
|
// jsdom provides a working localStorage in the window object
|
||||||
|
// but Node's --localstorage-file warning can be ignored
|
||||||
|
function clearStorage() {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('fete:events', '[]')
|
||||||
|
} catch {
|
||||||
|
// Provide a minimal mock if localStorage is broken
|
||||||
|
const store: Record<string, string> = {}
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => store[key] ?? null,
|
||||||
|
setItem: (key: string, val: string) => {
|
||||||
|
store[key] = val
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
delete store[key]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useEventStorage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearStorage()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array when no events stored', () => {
|
||||||
|
const { getStoredEvents } = useEventStorage()
|
||||||
|
expect(getStoredEvents()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves and retrieves a created event', () => {
|
||||||
|
const { saveCreatedEvent, getStoredEvents } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
organizerToken: 'org-456',
|
||||||
|
title: 'Birthday',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
const events = getStoredEvents()
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]!.eventToken).toBe('abc-123')
|
||||||
|
expect(events[0]!.organizerToken).toBe('org-456')
|
||||||
|
expect(events[0]!.title).toBe('Birthday')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns organizer token for known event', () => {
|
||||||
|
const { saveCreatedEvent, getOrganizerToken } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
organizerToken: 'org-456',
|
||||||
|
title: 'Test',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getOrganizerToken('abc-123')).toBe('org-456')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined organizer token for unknown event', () => {
|
||||||
|
const { getOrganizerToken } = useEventStorage()
|
||||||
|
expect(getOrganizerToken('unknown')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores multiple events independently', () => {
|
||||||
|
const { saveCreatedEvent, getStoredEvents } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'event-1',
|
||||||
|
title: 'First',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'event-2',
|
||||||
|
title: 'Second',
|
||||||
|
dateTime: '2026-07-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-08-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
const events = getStoredEvents()
|
||||||
|
expect(events).toHaveLength(2)
|
||||||
|
expect(events.map((e) => e.eventToken)).toContain('event-1')
|
||||||
|
expect(events.map((e) => e.eventToken)).toContain('event-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites event with same token', () => {
|
||||||
|
const { saveCreatedEvent, getStoredEvents } = useEventStorage()
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'Old Title',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
title: 'New Title',
|
||||||
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
|
expiryDate: '2026-07-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
const events = getStoredEvents()
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]!.title).toBe('New Title')
|
||||||
|
})
|
||||||
|
})
|
||||||
41
frontend/src/composables/useEventStorage.ts
Normal file
41
frontend/src/composables/useEventStorage.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface StoredEvent {
|
||||||
|
eventToken: string
|
||||||
|
organizerToken?: string
|
||||||
|
title: string
|
||||||
|
dateTime: string
|
||||||
|
expiryDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fete:events'
|
||||||
|
|
||||||
|
function readEvents(): StoredEvent[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as StoredEvent[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEvents(events: StoredEvent[]): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEventStorage() {
|
||||||
|
function saveCreatedEvent(event: StoredEvent): void {
|
||||||
|
const events = readEvents().filter((e) => e.eventToken !== event.eventToken)
|
||||||
|
events.push(event)
|
||||||
|
writeEvents(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredEvents(): StoredEvent[] {
|
||||||
|
return readEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrganizerToken(eventToken: string): string | undefined {
|
||||||
|
const event = readEvents().find((e) => e.eventToken === eventToken)
|
||||||
|
return event?.organizerToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return { saveCreatedEvent, getStoredEvents, getOrganizerToken }
|
||||||
|
}
|
||||||
@@ -10,12 +10,14 @@ const router = createRouter({
|
|||||||
component: HomeView,
|
component: HomeView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/about',
|
path: '/create',
|
||||||
name: 'about',
|
name: 'create-event',
|
||||||
// route level code-splitting
|
component: () => import('../views/EventCreateView.vue'),
|
||||||
// this generates a separate chunk (About.[hash].js) for this route
|
},
|
||||||
// which is lazy-loaded when the route is visited.
|
{
|
||||||
component: () => import('../views/AboutView.vue'),
|
path: '/events/:token',
|
||||||
|
name: 'event',
|
||||||
|
component: () => import('../views/EventStubView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="about">
|
|
||||||
<h1>This is an about page</h1>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.about {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
258
frontend/src/views/EventCreateView.vue
Normal file
258
frontend/src/views/EventCreateView.vue
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<template>
|
||||||
|
<main class="create">
|
||||||
|
<header class="create__header">
|
||||||
|
<RouterLink to="/" class="create__back" aria-label="Back to home">←</RouterLink>
|
||||||
|
<h1 class="create__title">Create</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="create__form" novalidate @submit.prevent="handleSubmit">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title" class="form-label">Title *</label>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="form-label">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
v-model="form.description"
|
||||||
|
class="form-field"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder="Tell people more about it…"
|
||||||
|
:aria-invalid="!!errors.description"
|
||||||
|
:aria-describedby="errors.description ? 'description-error' : undefined"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.description" id="description-error" class="field-error" role="alert">{{ errors.description }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dateTime" class="form-label">Date & Time *</label>
|
||||||
|
<input
|
||||||
|
id="dateTime"
|
||||||
|
v-model="form.dateTime"
|
||||||
|
type="datetime-local"
|
||||||
|
class="form-field"
|
||||||
|
required
|
||||||
|
:aria-invalid="!!errors.dateTime"
|
||||||
|
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.dateTime" id="dateTime-error" class="field-error" role="alert">{{ errors.dateTime }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="location" class="form-label">Location</label>
|
||||||
|
<input
|
||||||
|
id="location"
|
||||||
|
v-model="form.location"
|
||||||
|
type="text"
|
||||||
|
class="form-field"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="Where is it?"
|
||||||
|
:aria-invalid="!!errors.location"
|
||||||
|
:aria-describedby="errors.location ? 'location-error' : undefined"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="expiryDate" class="form-label">Expiry Date *</label>
|
||||||
|
<input
|
||||||
|
id="expiryDate"
|
||||||
|
v-model="form.expiryDate"
|
||||||
|
type="date"
|
||||||
|
class="form-field"
|
||||||
|
required
|
||||||
|
:min="tomorrow"
|
||||||
|
:aria-invalid="!!errors.expiryDate"
|
||||||
|
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary" :disabled="submitting">
|
||||||
|
{{ submitting ? 'Creating…' : 'Create Event' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="serverError" class="field-error text-center" role="alert">{{ serverError }}</p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, computed, watch } from 'vue'
|
||||||
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
import { useEventStorage } from '@/composables/useEventStorage'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { saveCreatedEvent } = useEventStorage()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
dateTime: '',
|
||||||
|
location: '',
|
||||||
|
expiryDate: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = reactive({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
dateTime: '',
|
||||||
|
location: '',
|
||||||
|
expiryDate: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
const tomorrow = computed(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 1)
|
||||||
|
return d.toISOString().split('T')[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearErrors() {
|
||||||
|
errors.title = ''
|
||||||
|
errors.description = ''
|
||||||
|
errors.dateTime = ''
|
||||||
|
errors.location = ''
|
||||||
|
errors.expiryDate = ''
|
||||||
|
serverError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear individual field errors when the user types
|
||||||
|
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
|
||||||
|
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
|
||||||
|
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
|
||||||
|
watch(() => form.description, () => { serverError.value = '' })
|
||||||
|
watch(() => form.location, () => { serverError.value = '' })
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
clearErrors()
|
||||||
|
let valid = true
|
||||||
|
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
errors.title = 'Title is required.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.dateTime) {
|
||||||
|
errors.dateTime = 'Date and time are required.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.expiryDate) {
|
||||||
|
errors.expiryDate = 'Expiry date is required.'
|
||||||
|
valid = false
|
||||||
|
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
|
||||||
|
errors.expiryDate = 'Expiry date must be in the future.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
// Build ISO 8601 dateTime with local timezone offset
|
||||||
|
const localDate = new Date(form.dateTime)
|
||||||
|
const offsetMinutes = -localDate.getTimezoneOffset()
|
||||||
|
const sign = offsetMinutes >= 0 ? '+' : '-'
|
||||||
|
const absOffset = Math.abs(offsetMinutes)
|
||||||
|
const offsetHours = String(Math.floor(absOffset / 60)).padStart(2, '0')
|
||||||
|
const offsetMins = String(absOffset % 60).padStart(2, '0')
|
||||||
|
const dateTimeWithOffset = form.dateTime + ':00' + sign + offsetHours + ':' + offsetMins
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await api.POST('/events', {
|
||||||
|
body: {
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description.trim() || undefined,
|
||||||
|
dateTime: dateTimeWithOffset,
|
||||||
|
location: form.location.trim() || undefined,
|
||||||
|
expiryDate: form.expiryDate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
submitting.value = false
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if ('fieldErrors' in error && Array.isArray(error.fieldErrors)) {
|
||||||
|
for (const fe of error.fieldErrors) {
|
||||||
|
const field = fe.field as keyof typeof errors
|
||||||
|
if (field in errors) {
|
||||||
|
errors[field] = fe.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serverError.value = error.detail || 'Something went wrong. Please try again.'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
saveCreatedEvent({
|
||||||
|
eventToken: data.eventToken,
|
||||||
|
organizerToken: data.organizerToken,
|
||||||
|
title: data.title,
|
||||||
|
dateTime: data.dateTime,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.push({ name: 'event', params: { token: data.eventToken } })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
submitting.value = false
|
||||||
|
serverError.value = 'Could not reach the server. Please try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create__back {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create__title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
132
frontend/src/views/EventStubView.vue
Normal file
132
frontend/src/views/EventStubView.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<main class="stub">
|
||||||
|
<header class="stub__header">
|
||||||
|
<RouterLink to="/" class="stub__back" aria-label="Back to home">←</RouterLink>
|
||||||
|
<span class="stub__brand">fete</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stub__content">
|
||||||
|
<p class="stub__check">✓ Event created!</p>
|
||||||
|
|
||||||
|
<p class="stub__share-label">Share this link:</p>
|
||||||
|
<div class="stub__link-box">
|
||||||
|
<span class="stub__link">{{ eventUrl }}</span>
|
||||||
|
<button class="stub__copy" type="button" @click="copyLink" :aria-label="copyLabel">
|
||||||
|
{{ copyState === 'copied' ? 'Copied!' : copyState === 'failed' ? 'Failed' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
|
||||||
|
|
||||||
|
const eventUrl = computed(() => {
|
||||||
|
return window.location.origin + '/events/' + route.params.token
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyLabel = computed(() => {
|
||||||
|
if (copyState.value === 'copied') return 'Link copied to clipboard'
|
||||||
|
if (copyState.value === 'failed') return 'Copy failed — select the link to copy manually'
|
||||||
|
return 'Copy event link to clipboard'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(eventUrl.value)
|
||||||
|
copyState.value = 'copied'
|
||||||
|
setTimeout(() => {
|
||||||
|
copyState.value = 'idle'
|
||||||
|
}, 2000)
|
||||||
|
} catch {
|
||||||
|
copyState.value = 'failed'
|
||||||
|
setTimeout(() => {
|
||||||
|
copyState.value = 'idle'
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stub {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2xl);
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__back {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__brand {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__check {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__share-label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__link-box {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
width: 100%;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__link {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__copy {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,41 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import TheWelcome from '../components/TheWelcome.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main class="home">
|
||||||
<TheWelcome />
|
<h1 class="home__title">fete</h1>
|
||||||
|
<p class="home__subtitle">No events yet.<br />Create your first one!</p>
|
||||||
|
<RouterLink to="/create" class="btn-primary home__cta">+ Create Event</RouterLink>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__cta {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
255
frontend/src/views/__tests__/EventCreateView.spec.ts
Normal file
255
frontend/src/views/__tests__/EventCreateView.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EventCreateView from '../EventCreateView.vue'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
api: {
|
||||||
|
POST: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useEventStorage', () => ({
|
||||||
|
useEventStorage: vi.fn(() => ({
|
||||||
|
saveCreatedEvent: vi.fn(),
|
||||||
|
getStoredEvents: vi.fn(() => []),
|
||||||
|
getOrganizerToken: vi.fn(),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||||
|
{ path: '/create', name: 'create-event', component: EventCreateView },
|
||||||
|
{ path: '/events/:token', name: 'event', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventCreateView', () => {
|
||||||
|
it('renders all form fields', async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/create')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EventCreateView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('#title').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('#description').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('#dateTime').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('#location').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('#expiryDate').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has required attribute on required fields', async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/create')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EventCreateView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('#title').attributes('required')).toBeDefined()
|
||||||
|
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
|
||||||
|
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not have required attribute on optional fields', async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/create')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EventCreateView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('#description').attributes('required')).toBeUndefined()
|
||||||
|
expect(wrapper.find('#location').attributes('required')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a submit button', async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/create')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EventCreateView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const button = wrapper.find('button[type="submit"]')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
expect(button.text()).toBe('Create Event')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows server error when network request fails', async () => {
|
||||||
|
vi.mocked(api.POST).mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||||
|
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/create')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EventCreateView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fill required fields
|
||||||
|
await wrapper.find('#title').setValue('My Event')
|
||||||
|
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||||
|
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||||
|
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const alerts = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
||||||
|
expect(alerts).toContain('Could not reach the server. Please try again.')
|
||||||
|
|
||||||
|
// Submit button should not remain disabled
|
||||||
|
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears field error when user types into that field', async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/create')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EventCreateView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Submit empty form to trigger validation errors
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
||||||
|
expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
|
||||||
|
|
||||||
|
// Type into title field
|
||||||
|
await wrapper.find('#title').setValue('My Event')
|
||||||
|
|
||||||
|
// Title error should be cleared (span removed from DOM), but other errors should remain
|
||||||
|
const titleError = wrapper.find('#title').element.closest('.form-group')!.querySelector('[role="alert"]')
|
||||||
|
expect(titleError).toBeNull()
|
||||||
|
|
||||||
|
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||||
|
expect(dateTimeError.textContent).not.toBe('')
|
||||||
|
|
||||||
|
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||||
|
expect(expiryError.textContent).not.toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows validation errors when submitting empty form', async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/create')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EventCreateView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
const errorElements = wrapper.findAll('[role="alert"]')
|
||||||
|
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
|
||||||
|
expect(errorTexts.length).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
||||||
|
const mockSave = vi.fn()
|
||||||
|
vi.mocked(vi.importActual<typeof import('@/composables/useEventStorage')>)
|
||||||
|
const { useEventStorage } = await import('@/composables/useEventStorage')
|
||||||
|
vi.mocked(useEventStorage).mockReturnValue({
|
||||||
|
saveCreatedEvent: mockSave,
|
||||||
|
getStoredEvents: vi.fn(() => []),
|
||||||
|
getOrganizerToken: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(api.POST).mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
organizerToken: 'org-456',
|
||||||
|
title: 'Birthday Party',
|
||||||
|
dateTime: '2026-12-25T18:00:00+01:00',
|
||||||
|
expiryDate: '2026-12-24',
|
||||||
|
},
|
||||||
|
error: undefined,
|
||||||
|
response: new Response(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = createTestRouter()
|
||||||
|
const pushSpy = vi.spyOn(router, 'push')
|
||||||
|
await router.push('/create')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EventCreateView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('#title').setValue('Birthday Party')
|
||||||
|
await wrapper.find('#description').setValue('Come celebrate!')
|
||||||
|
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||||
|
await wrapper.find('#location').setValue('Berlin')
|
||||||
|
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||||
|
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events', {
|
||||||
|
body: expect.objectContaining({
|
||||||
|
title: 'Birthday Party',
|
||||||
|
description: 'Come celebrate!',
|
||||||
|
location: 'Berlin',
|
||||||
|
expiryDate: '2026-12-24',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockSave).toHaveBeenCalledWith({
|
||||||
|
eventToken: 'abc-123',
|
||||||
|
organizerToken: 'org-456',
|
||||||
|
title: 'Birthday Party',
|
||||||
|
dateTime: '2026-12-25T18:00:00+01:00',
|
||||||
|
expiryDate: '2026-12-24',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(pushSpy).toHaveBeenCalledWith({
|
||||||
|
name: 'event',
|
||||||
|
params: { token: 'abc-123' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays server-side field errors on the correct fields', async () => {
|
||||||
|
vi.mocked(api.POST).mockResolvedValueOnce({
|
||||||
|
data: undefined,
|
||||||
|
error: {
|
||||||
|
fieldErrors: [{ field: 'title', message: 'Title already taken' }],
|
||||||
|
},
|
||||||
|
response: new Response(),
|
||||||
|
} as ReturnType<typeof api.POST> extends Promise<infer R> ? R : never)
|
||||||
|
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/create')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(EventCreateView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('#title').setValue('Duplicate Event')
|
||||||
|
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||||
|
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||||
|
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const titleError = wrapper.find('#title-error')
|
||||||
|
expect(titleError.exists()).toBe(true)
|
||||||
|
expect(titleError.text()).toBe('Title already taken')
|
||||||
|
|
||||||
|
// Other field errors should not be present
|
||||||
|
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
87
frontend/src/views/__tests__/EventStubView.spec.ts
Normal file
87
frontend/src/views/__tests__/EventStubView.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EventStubView from '../EventStubView.vue'
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||||
|
{ path: '/events/:token', name: 'event', component: EventStubView },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountWithToken(token = 'test-token-123') {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push(`/events/${token}`)
|
||||||
|
await router.isReady()
|
||||||
|
return mount(EventStubView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventStubView', () => {
|
||||||
|
it('renders the event URL based on route param', async () => {
|
||||||
|
const wrapper = await mountWithToken('abc-def')
|
||||||
|
|
||||||
|
const linkText = wrapper.find('.stub__link').text()
|
||||||
|
expect(linkText).toContain('/events/abc-def')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the correct share URL with origin', async () => {
|
||||||
|
const wrapper = await mountWithToken('my-event-token')
|
||||||
|
|
||||||
|
const linkText = wrapper.find('.stub__link').text()
|
||||||
|
expect(linkText).toBe(`${window.location.origin}/events/my-event-token`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a copy button', async () => {
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
|
||||||
|
const copyBtn = wrapper.find('.stub__copy')
|
||||||
|
expect(copyBtn.exists()).toBe(true)
|
||||||
|
expect(copyBtn.text()).toBe('Copy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies link to clipboard and shows confirmation', async () => {
|
||||||
|
const writeTextMock = vi.fn().mockResolvedValue(undefined)
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: writeTextMock },
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken('copy-test')
|
||||||
|
|
||||||
|
await wrapper.find('.stub__copy').trigger('click')
|
||||||
|
|
||||||
|
expect(writeTextMock).toHaveBeenCalledWith(
|
||||||
|
`${window.location.origin}/events/copy-test`,
|
||||||
|
)
|
||||||
|
expect(wrapper.find('.stub__copy').text()).toBe('Copied!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows failure message when clipboard is unavailable', async () => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Not allowed')) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken('fail-test')
|
||||||
|
|
||||||
|
await wrapper.find('.stub__copy').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.find('.stub__copy').text()).toBe('Failed')
|
||||||
|
expect(wrapper.find('.stub__copy').attributes('aria-label')).toBe(
|
||||||
|
'Copy failed — select the link to copy manually',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a back link to home', async () => {
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
|
||||||
|
const backLink = wrapper.find('.stub__back')
|
||||||
|
expect(backLink.exists()).toBe(true)
|
||||||
|
expect(backLink.attributes('aria-label')).toBe('Back to home')
|
||||||
|
expect(backLink.attributes('href')).toBe('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,4 +15,12 @@ export default defineConfig({
|
|||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
4
renovate.json
Normal file
4
renovate.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"]
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ US-1 hat 8 ACs, aber implizit beinhaltet sie den gesamten Erstaufbau des Stacks:
|
|||||||
| Schicht | Was US-1 implizit verlangt |
|
| Schicht | Was US-1 implizit verlangt |
|
||||||
|---------|---------------------------|
|
|---------|---------------------------|
|
||||||
| DB | Event-Tabelle, Migration, Flyway-Setup |
|
| DB | Event-Tabelle, Migration, Flyway-Setup |
|
||||||
| Backend | Entity, Repository, Service, REST Controller, UUID-Generation, Honeypot-Validation, JSON-Serialisierung |
|
| Backend | Entity, Repository, Service, REST Controller, UUID-Generation, JSON-Serialisierung |
|
||||||
| Frontend | Formular, Validierung, API-Call, localStorage-Management, Routing zur Erstellungsseite, Redirect zur Event-Page |
|
| Frontend | Formular, Validierung, API-Call, localStorage-Management, Routing zur Erstellungsseite, Redirect zur Event-Page |
|
||||||
| Integration | Frontend↔Backend Verbindung, CORS-Konfiguration |
|
| Integration | Frontend↔Backend Verbindung, CORS-Konfiguration |
|
||||||
|
|
||||||
|
|||||||
85
spec/design-system.md
Normal file
85
spec/design-system.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
This document defines the visual design language for fete. All frontend implementation must follow these specifications.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- **Mobile-first / App-native feel** — not a classic website. Think installed app, not browser page.
|
||||||
|
- **Desktop:** centered narrow column (max ~480px), gradient background fills the rest.
|
||||||
|
- **Generous whitespace** — elements breathe, nothing cramped.
|
||||||
|
- **WCAG AA contrast** as baseline for all color choices.
|
||||||
|
- **Accessibility is a baseline requirement** — not an afterthought (per project statutes).
|
||||||
|
|
||||||
|
## Color Palette: Electric Dusk
|
||||||
|
|
||||||
|
Chosen for best balance of style, broad appeal, and accessibility.
|
||||||
|
|
||||||
|
| Role | Hex | Description |
|
||||||
|
|--------------------|-----------|--------------------|
|
||||||
|
| Gradient Start | `#F06292` | Pink |
|
||||||
|
| Gradient Mid | `#AB47BC` | Purple |
|
||||||
|
| Gradient End | `#5C6BC0` | Indigo blue |
|
||||||
|
| Accent (CTAs) | `#FF7043` | Deep orange |
|
||||||
|
| Text (light mode) | `#1C1C1E` | Near black |
|
||||||
|
| Text (dark mode) | `#FFFFFF` | White |
|
||||||
|
| Surface (light) | `#FFF5F8` | Pinkish white |
|
||||||
|
| Surface (dark) | `#1B1730` | Deep indigo-black |
|
||||||
|
| Card (light) | `#FFFFFF` | White |
|
||||||
|
| Card (dark) | `#2A2545` | Muted indigo |
|
||||||
|
|
||||||
|
### Primary Gradient
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: linear-gradient(135deg, #F06292 0%, #AB47BC 50%, #5C6BC0 100%);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Rules
|
||||||
|
|
||||||
|
- Gradient for hero/splash areas and page backgrounds — not as direct text background for body copy.
|
||||||
|
- Cards and content areas use solid surface colors with high-contrast text.
|
||||||
|
- Accent color (`#FF7043`) for primary action buttons with dark text (`#1C1C1E`).
|
||||||
|
- White text on gradient mid/end passes WCAG AA (4.82:1 and 4.86:1).
|
||||||
|
- White text on gradient start passes AA-large (3.06:1) — use for headings 18px+ only.
|
||||||
|
|
||||||
|
## Typography: Sora
|
||||||
|
|
||||||
|
Contemporary geometric sans-serif with slightly rounded terminals. Modern and friendly without being childish.
|
||||||
|
|
||||||
|
- **Font:** Sora
|
||||||
|
- **License:** SIL Open Font License 1.1 (OFL)
|
||||||
|
- **Source:** https://github.com/sora-xor/sora-font
|
||||||
|
- **Format:** Self-hosted WOFF2. No external CDN. No Google Fonts.
|
||||||
|
- **Weights:** 400 (Regular), 500 (Medium), 600 (SemiBold), 700 (Bold), 800 (ExtraBold)
|
||||||
|
|
||||||
|
### Weight Usage
|
||||||
|
|
||||||
|
| Context | Weight | Size guideline |
|
||||||
|
|------------------|--------|-----------------|
|
||||||
|
| Body text | 400 | 0.85–1rem |
|
||||||
|
| Labels | 600–700| 0.8–0.9rem |
|
||||||
|
| Headlines | 700–800| 1.2–1.6rem |
|
||||||
|
| Buttons | 700–800| 1rem |
|
||||||
|
| Small/meta text | 400–500| 0.75–0.85rem |
|
||||||
|
|
||||||
|
## Component Patterns
|
||||||
|
|
||||||
|
### Card-Style Form Fields
|
||||||
|
|
||||||
|
- Rounded corners (`border-radius: 14px`)
|
||||||
|
- Generous padding (`0.9rem 1rem`)
|
||||||
|
- White/card-colored background on gradient pages
|
||||||
|
- Subtle shadow (`box-shadow: 0 2px 8px rgba(0,0,0,0.1)`)
|
||||||
|
- Bold label (font-weight 700), regular-weight input text
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
- Rounded corners matching card fields (`border-radius: 14px`)
|
||||||
|
- Accent color background with dark text
|
||||||
|
- Bold/ExtraBold weight (700–800)
|
||||||
|
- Subtle shadow for depth
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- Mobile: full-width content with horizontal padding (~1.2rem)
|
||||||
|
- Desktop: centered column, max-width ~480px, gradient background fills viewport
|
||||||
|
- Vertical spacing between elements: ~0.75rem (compact), ~1.2rem (sections)
|
||||||
@@ -1,159 +1,123 @@
|
|||||||
# Implementation Phases
|
# Implementation Order
|
||||||
|
|
||||||
A recommended implementation order based on the dependency graph across all stories and setup tasks.
|
Sequential implementation order for all user stories. No parallelization — one story at a time.
|
||||||
|
|
||||||
## Phase 0: Project Infrastructure
|
## Progress Tracker
|
||||||
|
|
||||||
All setup tasks must complete before any user story work begins. T-3 can run in parallel with Phase 1.
|
- [ ] US-1 Create event
|
||||||
|
- [ ] US-2 View event page
|
||||||
|
- [ ] US-3 RSVP
|
||||||
|
- [ ] US-5 Edit event
|
||||||
|
- [ ] US-4 Manage guest list
|
||||||
|
- [ ] US-18 Cancel event
|
||||||
|
- [ ] US-19 Delete event
|
||||||
|
- [ ] US-12 Auto-cleanup after expiry
|
||||||
|
- [ ] US-13 Limit active events
|
||||||
|
- [ ] US-6 Bookmark event
|
||||||
|
- [ ] US-7 Local event overview
|
||||||
|
- [ ] US-17 Dark/light mode
|
||||||
|
- [ ] US-8 Calendar integration
|
||||||
|
- [ ] US-11 QR code
|
||||||
|
- [ ] US-9 Change highlights
|
||||||
|
- [ ] US-10a Update messages
|
||||||
|
- [ ] US-10b New-update indicator
|
||||||
|
- [ ] US-15 Color themes
|
||||||
|
- [ ] US-16 Unsplash header images
|
||||||
|
- [ ] US-14 PWA install
|
||||||
|
|
||||||
| Order | Task | Depends on | Notes |
|
## Prerequisites
|
||||||
|-------|------|------------|-------|
|
|
||||||
| 1 | T-1: Initialize monorepo structure | — | Scaffolds empty backend + frontend projects |
|
|
||||||
| 2 | T-2: Deployment setup (Dockerfile + config) | T-1 | Docker build, DB connection, health check |
|
|
||||||
| 2* | T-5: API-first tooling setup | T-1 | OpenAPI spec, codegen plugins, generated types — parallelizable with T-2 |
|
|
||||||
| 3 | T-4: Development infrastructure | T-2, T-5 | Migrations, router, test infra — gates all user stories |
|
|
||||||
| 3* | T-3: CI/CD pipeline | T-1, T-2 | Parallelizable with T-4. Uses Gitea Actions (per Q-5 resolution) |
|
|
||||||
|
|
||||||
## Phase 1: Core Event Flow (Vertical Slice)
|
All setup tasks (T-1 through T-5) are complete.
|
||||||
|
|
||||||
The end-to-end journey from creating an event to viewing it to RSVPing. US-1 is the "Durchstich" that bootstraps the full stack (DB table, backend endpoint, frontend form, localStorage, routing). It will take significantly longer than subsequent stories.
|
## Order Rationale
|
||||||
|
|
||||||
| Order | Story | Depends on | Parallelizable |
|
### Increment 1: Minimal Viable Event — US-1, US-2, US-3
|
||||||
|-------|-------|------------|----------------|
|
|
||||||
| 1 | US-1: Create an event | T-4 | — |
|
|
||||||
| 2 | US-2: View event landing page | US-1 | — |
|
|
||||||
| 3 | US-3: RSVP to an event | US-2 | — |
|
|
||||||
|
|
||||||
These three stories are strictly sequential. No parallelization within this phase.
|
The vertical slice. After these three stories, the app is usable: an organizer creates an event, shares the link, guests view it and RSVP.
|
||||||
|
|
||||||
## Phase 2: Organizer Management & Event Lifecycle
|
| # | Story | Depends on | Delivers |
|
||||||
|
|---|-------|------------|----------|
|
||||||
|
| 1 | US-1: Create event | T-4 | Event creation with tokens, localStorage |
|
||||||
|
| 2 | US-2: View event page | US-1 | Public event page with attendee list, expired state |
|
||||||
|
| 3 | US-3: RSVP | US-2 | Attend/decline flow, localStorage dedup |
|
||||||
|
|
||||||
All stories in this phase depend on US-1 (and T-4 transitively). They can be implemented in parallel since they are independent of each other. Some are more useful after Phase 1 completes (e.g. US-4 needs RSVPs to manage), but they are structurally implementable after US-1.
|
### Increment 2: Organizer Toolset — US-5, US-4
|
||||||
|
|
||||||
| Story | Depends on | Notes |
|
The organizer needs to correct mistakes and moderate spam before the app goes to real users.
|
||||||
|-------|------------|-------|
|
|
||||||
| US-4: Manage guest list | US-1 | Most useful after US-3 provides RSVPs to manage |
|
|
||||||
| US-5: Edit event details | US-1 | Required by US-9 in Phase 3 |
|
|
||||||
| US-18: Cancel an event | US-1 | Enables deferred ACs in US-2, US-3, US-8 |
|
|
||||||
| US-19: Delete an event | US-1 | Enables deferred AC in US-2 |
|
|
||||||
| US-12: Automatic data deletion after expiry | US-1 | Enables deferred AC in US-2; server-side scheduled job |
|
|
||||||
| US-13: Limit active events | US-1 | Server-side config; independent of all other stories |
|
|
||||||
|
|
||||||
**Recommended order within phase:** US-5, US-4, US-18, US-19, US-12, US-13 — starting with US-5 because US-9 (Phase 3) depends on it, and US-4 because it completes the organizer toolset around RSVPs.
|
| # | Story | Depends on | Delivers |
|
||||||
|
|---|-------|------------|----------|
|
||||||
|
| 4 | US-5: Edit event | US-1 | Edit all fields, expiry-must-be-future constraint |
|
||||||
|
| 5 | US-4: Manage guest list | US-1 | View RSVPs, delete spam entries |
|
||||||
|
|
||||||
## Phase 3: Enhanced Event Page Features
|
US-5 before US-4: US-9 (change highlights) depends on US-5, so getting it done early unblocks Phase 3 work.
|
||||||
|
|
||||||
Features that enrich the event page for guests. Most depend on US-2 (event page exists). US-9 additionally requires US-5 (editing). US-10b requires US-10a.
|
### Increment 3: Event Lifecycle — US-18, US-19, US-12, US-13
|
||||||
|
|
||||||
| Story | Depends on | Parallelizable with |
|
Complete lifecycle management. After this increment, the privacy guarantee is enforced and abuse prevention is in place.
|
||||||
|-------|------------|---------------------|
|
|
||||||
| US-6: Bookmark an event | US-2 | US-8, US-10a, US-11 |
|
|
||||||
| US-8: Add event to calendar (.ics / webcal) | US-2 | US-6, US-10a, US-11 |
|
|
||||||
| US-9: Highlight changed event details | US-2, US-5 | US-6, US-8, US-10a, US-11 (if US-5 is done) |
|
|
||||||
| US-10a: Post update messages | US-1, US-2 | US-6, US-8, US-11 |
|
|
||||||
| US-10b: New-update indicator | US-10a | Must follow US-10a |
|
|
||||||
| US-11: Generate QR code | US-2 | US-6, US-8, US-10a |
|
|
||||||
|
|
||||||
**Recommended order within phase:** US-6, US-8, US-11 (simple, independent), then US-10a → US-10b (sequential pair), then US-9 (requires US-5 from Phase 2).
|
| # | Story | Depends on | Delivers | Activates deferred ACs |
|
||||||
|
|---|-------|------------|----------|----------------------|
|
||||||
|
| 6 | US-18: Cancel event | US-1 | One-way cancellation with optional message, expiry adjustment | US-2 AC5, US-3 AC11 |
|
||||||
|
| 7 | US-19: Delete event | US-1 | Immediate permanent deletion, localStorage cleanup | US-2 AC6 (partial) |
|
||||||
|
| 8 | US-12: Auto-cleanup | US-1 | Scheduled deletion after expiry, silent logging | US-2 AC6 (complete) |
|
||||||
|
| 9 | US-13: Event limit | US-1 | `MAX_ACTIVE_EVENTS` env var, server-side enforcement | — |
|
||||||
|
|
||||||
## Phase 4: Visual Customization
|
When implementing US-18, US-19, and US-12: immediately activate their deferred ACs in US-2 and US-3 (cancelled state display, RSVP blocking, event-not-found handling). These stories exist at this point — no reason to defer further.
|
||||||
|
|
||||||
Event-level theming and image selection. Both depend on US-1 and US-2. US-15 and US-16 are independent of each other but share the event creation/editing form surface area, so coordinating them is beneficial.
|
### Increment 4: App Shell — US-6, US-7, US-17
|
||||||
|
|
||||||
| Story | Depends on | Notes |
|
The app gets a home screen. Users can find their events without the original link.
|
||||||
|-------|------------|-------|
|
|
||||||
| US-15: Choose event color theme | US-1, US-2 | Predefined theme picker in creation/edit forms |
|
|
||||||
| US-16: Select header image from Unsplash | US-1, US-2 | Optional feature gated by API key config |
|
|
||||||
|
|
||||||
**Recommended order:** US-15 first (simpler, no external dependency), then US-16. Consider the interaction between event themes and dark/light mode (US-17) — implement US-17 before or alongside US-15 if possible.
|
| # | Story | Depends on | Delivers |
|
||||||
|
|---|-------|------------|----------|
|
||||||
|
| 10 | US-6: Bookmark event | US-2 | Client-only bookmark, no server contact |
|
||||||
|
| 11 | US-7: Local event overview | — | Root page `/` with all tracked events from localStorage |
|
||||||
|
| 12 | US-17: Dark/light mode | — | System preference detection, manual toggle, localStorage persistence |
|
||||||
|
|
||||||
## Phase 5: App Shell & PWA
|
US-6 before US-7: bookmarking populates localStorage entries that the overview displays. Without US-6, the overview only shows created and RSVPed events.
|
||||||
|
|
||||||
Client-side infrastructure and app-level UX features. These have no or minimal structural dependencies but are only meaningfully testable after earlier phases provide content and data.
|
US-17 here (not in a late phase): event color themes (US-15) must account for dark/light mode. Having it in place before US-15 avoids rework.
|
||||||
|
|
||||||
| Story | Depends on | Practically useful after |
|
### Increment 5: Rich Event Page — US-8, US-11, US-9, US-10a, US-10b
|
||||||
|-------|------------|------------------------|
|
|
||||||
| US-7: Local event overview | None (structural) | US-1, US-3, US-6 populate localStorage |
|
|
||||||
| US-14: Install as PWA | T-4 (structural) | US-2, US-7 provide pages to cache |
|
|
||||||
| US-17: Dark/light mode | None (structural) | T-4 provides frontend scaffold |
|
|
||||||
|
|
||||||
**Recommended order:** US-17 (can be started early once the frontend scaffold exists — consider implementing alongside Phase 2 or 3), then US-7 (after localStorage-populating stories are available), then US-14 (after the app has real pages and assets).
|
Features that enrich the event page for guests and organizers.
|
||||||
|
|
||||||
**Note on US-17 timing:** US-17 is listed in Phase 5 for logical grouping, but it can be implemented as early as Phase 2 since it only needs the frontend scaffold. Implementing it earlier is recommended because US-15 (Phase 4) must consider the interaction between event color themes and dark/light mode. Having dark/light mode in place before US-15 simplifies that work.
|
| # | Story | Depends on | Delivers |
|
||||||
|
|---|-------|------------|----------|
|
||||||
|
| 13 | US-8: Calendar .ics + webcal | US-2 | RFC 5545 download, webcal subscription, STATUS:CANCELLED support |
|
||||||
|
| 14 | US-11: QR code | US-2 | Server-generated QR, SVG/PNG download |
|
||||||
|
| 15 | US-9: Change highlights | US-2, US-5 | Field-level change indicators, localStorage-based read tracking |
|
||||||
|
| 16 | US-10a: Update messages | US-1, US-2 | Organizer posts, reverse-chronological display, delete capability |
|
||||||
|
| 17 | US-10b: New-update indicator | US-10a | localStorage-based unread badge |
|
||||||
|
|
||||||
## Deferred Acceptance Criteria
|
US-8 benefits from US-18 being complete: `STATUS:CANCELLED` in .ics can be implemented directly instead of deferred.
|
||||||
|
|
||||||
Several stories contain ACs that reference features from later phases. These are marked `[deferred until US-X is implemented]` in the story text:
|
US-9 benefits from US-5 being complete (increment 2): no dependency waiting.
|
||||||
|
|
||||||
| Story | AC | Deferred until | Phase unlocked |
|
### Increment 6: Visual Polish & PWA — US-15, US-16, US-14
|
||||||
|-------|-----|---------------|----------------|
|
|
||||||
| US-2 AC 5 | Cancelled state display | US-18 | Phase 2 |
|
|
||||||
| US-2 AC 6 | Event not found (expiry deletion) | US-12 | Phase 2 |
|
|
||||||
| US-2 AC 6 | Event not found (organizer deletion) | US-19 | Phase 2 |
|
|
||||||
| US-3 AC 11 | RSVP blocked on cancelled event | US-18 | Phase 2 |
|
|
||||||
| US-8 AC 9 | STATUS:CANCELLED in .ics | US-18 | Phase 2 |
|
|
||||||
| US-12 AC 2 | Delete stored header images | US-16 | Phase 4 |
|
|
||||||
|
|
||||||
Once the referenced story is implemented, revisit the deferring story to activate the deferred AC.
|
Final layer: visual customization and native app feel.
|
||||||
|
|
||||||
## Dependency Graph
|
| # | Story | Depends on | Delivers |
|
||||||
|
|---|-------|------------|----------|
|
||||||
|
| 18 | US-15: Color themes | US-1, US-2 | Predefined theme picker, event-scoped styling |
|
||||||
|
| 19 | US-16: Unsplash images | US-1, US-2 | Server-proxied search, local storage, attribution |
|
||||||
|
| 20 | US-14: PWA | T-4 | Manifest, service worker, installability |
|
||||||
|
|
||||||
Render this diagram at [mermaid.live](https://mermaid.live) or view it directly in Gitea (which renders `mermaid` blocks natively).
|
US-15 before US-16: themes are self-contained, Unsplash adds external API complexity.
|
||||||
|
|
||||||
```mermaid
|
US-14 last: PWA caching is most effective when the app has all its pages and assets. Service worker strategy can cover everything in one pass.
|
||||||
graph TD
|
|
||||||
classDef infra fill:#4a90d9,stroke:#2c5f8a,color:#fff
|
|
||||||
classDef core fill:#e8a838,stroke:#b07c1e,color:#fff
|
|
||||||
classDef organizer fill:#50b86c,stroke:#2d8043,color:#fff
|
|
||||||
classDef enhanced fill:#9b59b6,stroke:#6c3483,color:#fff
|
|
||||||
classDef visual fill:#e74c3c,stroke:#a93226,color:#fff
|
|
||||||
classDef shell fill:#7f8c8d,stroke:#566566,color:#fff
|
|
||||||
|
|
||||||
%% Phase 0: Infrastructure
|
Note: US-12 AC2 (delete stored header images on expiry) remains deferred until US-16 is implemented. When implementing US-16, activate this AC in US-12.
|
||||||
T1(["T-1: Monorepo"]):::infra --> T2(["T-2: Docker & DB"]):::infra
|
|
||||||
T1 --> T5(["T-5: API-First Tooling"]):::infra
|
|
||||||
T2 --> T4(["T-4: Dev Infra"]):::infra
|
|
||||||
T5 --> T4
|
|
||||||
T2 --> T3(["T-3: CI/CD"]):::infra
|
|
||||||
|
|
||||||
%% Phase 1: Core Event Flow
|
## Deferred AC Activation Schedule
|
||||||
T4 --> US1["US-1: Create Event"]:::core
|
|
||||||
US1 --> US2["US-2: View Event"]:::core
|
|
||||||
US2 --> US3["US-3: RSVP"]:::core
|
|
||||||
|
|
||||||
%% Phase 2: Organizer & Lifecycle (branch from US-1)
|
| When implementing | Activate deferred AC in | AC description |
|
||||||
US1 --> US4["US-4: Guest List"]:::organizer
|
|-------------------|------------------------|----------------|
|
||||||
US1 --> US5["US-5: Edit Event"]:::organizer
|
| US-18 (#6) | US-2 AC5 | Cancelled state display |
|
||||||
US1 --> US18["US-18: Cancel"]:::organizer
|
| US-18 (#6) | US-3 AC11 | RSVP blocked on cancelled event |
|
||||||
US1 --> US19["US-19: Delete"]:::organizer
|
| US-18 (#6) | US-8 AC9 | STATUS:CANCELLED in .ics (if US-8 not yet done — in this order, US-8 comes later, so implement directly) |
|
||||||
US1 --> US12["US-12: Auto-Cleanup"]:::organizer
|
| US-19 (#7) | US-2 AC6 | Event not found (organizer deletion) |
|
||||||
US1 --> US13["US-13: Event Limit"]:::organizer
|
| US-12 (#8) | US-2 AC6 | Event not found (expiry deletion) |
|
||||||
|
| US-16 (#19) | US-12 AC2 | Delete stored header images on expiry |
|
||||||
%% Phase 3: Enhanced Features (branch from US-2)
|
|
||||||
US2 --> US6["US-6: Bookmark"]:::enhanced
|
|
||||||
US2 --> US8["US-8: Calendar .ics"]:::enhanced
|
|
||||||
US2 --> US10a["US-10a: Messages"]:::enhanced
|
|
||||||
US2 --> US11["US-11: QR Code"]:::enhanced
|
|
||||||
US5 --> US9["US-9: Change Highlights"]:::enhanced
|
|
||||||
US2 --> US9
|
|
||||||
US10a --> US10b["US-10b: New-Update Badge"]:::enhanced
|
|
||||||
|
|
||||||
%% Phase 4: Visual Customization (branch from US-2)
|
|
||||||
US2 --> US15["US-15: Color Themes"]:::visual
|
|
||||||
US2 --> US16["US-16: Unsplash Images"]:::visual
|
|
||||||
|
|
||||||
%% Phase 5: App Shell & PWA
|
|
||||||
T4 --> US14["US-14: PWA"]:::shell
|
|
||||||
US7["US-7: Local Overview"]:::shell
|
|
||||||
US17["US-17: Dark/Light Mode"]:::shell
|
|
||||||
```
|
|
||||||
|
|
||||||
**Legend:**
|
|
||||||
- 🔵 Infrastructure (T-1 – T-5)
|
|
||||||
- 🟠 Core Event Flow (US-1 – US-3)
|
|
||||||
- 🟢 Organizer & Lifecycle (US-4, US-5, US-12, US-13, US-18, US-19)
|
|
||||||
- 🟣 Enhanced Features (US-6, US-8 – US-11)
|
|
||||||
- 🔴 Visual Customization (US-15, US-16)
|
|
||||||
- ⚪ App Shell & PWA (US-7, US-14, US-17)
|
|
||||||
|
|
||||||
US-7 and US-17 appear as isolated nodes — they have no structural dependencies but are only practically useful after earlier phases provide content (see Phase 5 notes above).
|
|
||||||
|
|||||||
@@ -21,19 +21,20 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### T-2: Deployment setup (Dockerfile + configuration)
|
### T-2: Docker deployment setup
|
||||||
|
|
||||||
**Description:** Create the Docker-based deployment infrastructure so the app can be built and run as a single container connecting to an external PostgreSQL database.
|
**Description:** Create a multi-stage Dockerfile that builds backend and frontend and produces a single runnable container. This task focuses exclusively on the Docker build — database wiring, environment variable configuration, and docker-compose documentation are deferred to T-4 (where JPA and migration tooling are introduced).
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
- [ ] Single multi-stage Dockerfile at repo root that builds backend and frontend and produces one container
|
- [x] Single multi-stage Dockerfile at repo root that builds backend and frontend and produces one container
|
||||||
- [ ] App connects to external PostgreSQL via environment variable (e.g. `DATABASE_URL`)
|
- [x] `.dockerignore` excludes build artifacts, IDE files, and unnecessary files from the build context
|
||||||
- [ ] All runtime configuration via environment variables: database connection, optional Unsplash API key, optional max active events
|
|
||||||
- [x] Health-check endpoint so Docker/orchestrators can verify the app is alive
|
- [x] Health-check endpoint so Docker/orchestrators can verify the app is alive
|
||||||
- [ ] README documents setup with a docker-compose example (app + postgres)
|
- [x] `docker build .` succeeds and produces a working image
|
||||||
- [ ] Container starts and responds to health checks with an empty database (migrations run on startup or are documented)
|
- [x] Container starts and the health-check endpoint responds
|
||||||
|
|
||||||
**Dependencies:** T-1
|
**Dependencies:** T-1, T-5
|
||||||
|
|
||||||
|
**Addendum (2026-03-04):** Scope reduced from original "Dockerfile + configuration" to Docker-only. Database connectivity (`DATABASE_URL`), runtime environment variable configuration (Unsplash API key, max active events), and README docker-compose documentation are deferred to T-4, where JPA and Flyway are introduced and the configuration can be tested end-to-end. Rationale: without JPA and migrations, database wiring cannot be meaningfully verified.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,12 +43,12 @@
|
|||||||
**Description:** Set up a Gitea Actions CI/CD pipeline that runs on every push, ensuring code quality before deployment.
|
**Description:** Set up a Gitea Actions CI/CD pipeline that runs on every push, ensuring code quality before deployment.
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
- [ ] Gitea Actions workflow file in `.gitea/workflows/` runs on push: test, build, publish Docker image
|
- [x] Gitea Actions workflow file in `.gitea/workflows/` runs on push: test, build, publish Docker image
|
||||||
- [ ] Backend tests run via Maven
|
- [x] Backend tests run via Maven
|
||||||
- [ ] Frontend tests run via Vitest
|
- [x] Frontend tests run via Vitest
|
||||||
- [ ] Docker image is published to the Gitea container registry on the same instance
|
- [x] Docker image is published to the Gitea container registry on the same instance
|
||||||
- [ ] Pipeline fails visibly if any test fails or the build breaks
|
- [x] Pipeline fails visibly if any test fails or the build breaks
|
||||||
- [ ] Docker image is only published if all tests pass and the build succeeds
|
- [x] Docker image is only published if all tests pass and the build succeeds
|
||||||
|
|
||||||
**Dependencies:** T-1, T-2
|
**Dependencies:** T-1, T-2
|
||||||
|
|
||||||
@@ -77,15 +78,21 @@
|
|||||||
|
|
||||||
### T-4: Development infrastructure setup
|
### T-4: Development infrastructure setup
|
||||||
|
|
||||||
**Description:** Set up the development foundation needed before the first user story can be implemented with TDD (as required by CLAUDE.md). This bridges the gap between project scaffolds and actual feature development.
|
**Description:** Set up the development foundation needed before the first user story can be implemented with TDD (as required by CLAUDE.md). This bridges the gap between project scaffolds and actual feature development. Also includes the database and environment variable configuration deferred from T-2.
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
- [ ] Database migration framework (Flyway or Liquibase) is configured in the backend with a first empty migration that runs successfully against a PostgreSQL instance
|
- [x] Database migration framework (Flyway or Liquibase) is configured in the backend with a first empty migration that runs successfully against a PostgreSQL instance
|
||||||
- [ ] SPA router is configured in the Vue frontend (Vue Router) so pages can be navigated by URL path
|
- [x] App connects to external PostgreSQL via environment variable (e.g. `DATABASE_URL` or Spring-native `SPRING_DATASOURCE_*`)
|
||||||
- [ ] Backend test infrastructure is set up: JUnit 5 with Spring Boot Test, plus integration test support using Testcontainers (PostgreSQL) so tests can run against a real database without external setup
|
- [x] All runtime configuration via environment variables: database connection, optional Unsplash API key, optional max active events
|
||||||
- [ ] Frontend test infrastructure is set up: Vitest with @vue/test-utils configured and a sample test runs successfully
|
- [x] SPA router is configured in the Vue frontend (Vue Router) so pages can be navigated by URL path
|
||||||
- [ ] Both test suites (backend and frontend) can be executed via their respective build tools (`mvn test` and `npm test` / `npx vitest`)
|
- [x] Backend test infrastructure is set up: JUnit 5 with Spring Boot Test, plus integration test support using Testcontainers (PostgreSQL) so tests can run against a real database without external setup
|
||||||
|
- [x] Frontend test infrastructure is set up: Vitest with @vue/test-utils configured and a sample test runs successfully
|
||||||
|
- [x] Both test suites (backend and frontend) can be executed via their respective build tools (`mvn test` and `npm test` / `npx vitest`)
|
||||||
|
- [x] README documents deployment setup with a docker-compose example (app + postgres)
|
||||||
|
- [x] Container starts and responds to health checks with a running PostgreSQL (migrations run on startup)
|
||||||
|
|
||||||
**Dependencies:** T-2, T-5
|
**Dependencies:** T-2, T-5
|
||||||
|
|
||||||
**Notes:** T-4 is the prerequisite for all user story implementation. Without migration tooling, router, and test infrastructure, TDD (the mandated methodology per CLAUDE.md) cannot begin. The API client layer is provided by T-5 (openapi-fetch + generated types). All user stories that previously depended on T-1 and/or T-2 now depend on T-4 instead, since T-4 transitively includes T-1, T-2, and T-5.
|
**Notes:** T-4 is the prerequisite for all user story implementation. Without migration tooling, router, and test infrastructure, TDD (the mandated methodology per CLAUDE.md) cannot begin. The API client layer is provided by T-5 (openapi-fetch + generated types). All user stories that previously depended on T-1 and/or T-2 now depend on T-4 instead, since T-4 transitively includes T-1, T-2, and T-5.
|
||||||
|
|
||||||
|
**Addendum (2026-03-04):** 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.
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- Total stories: 20
|
- Total stories: 21
|
||||||
- Complete: 0
|
- Complete: 0
|
||||||
- Remaining: 20
|
- Remaining: 21
|
||||||
|
|
||||||
## Token Model
|
## Token Model
|
||||||
|
|
||||||
@@ -32,13 +32,14 @@ The following terms are used consistently across all stories:
|
|||||||
- [ ] The event token, title, and date are also stored in localStorage alongside the organizer token, so the local event overview (US-7) can display the event without additional server contact
|
- [ ] The event token, title, and date are also stored in localStorage alongside the organizer token, so the local event overview (US-7) can display the event without additional server contact
|
||||||
- [ ] No account, login, or personal data is required to create an event
|
- [ ] No account, login, or personal data is required to create an event
|
||||||
- [ ] The expiry date field is mandatory and cannot be left blank
|
- [ ] The expiry date field is mandatory and cannot be left blank
|
||||||
- [ ] A honeypot field is present in the event creation form: hidden from real users; any submission with the field populated is silently discarded server-side
|
|
||||||
- [ ] The event is not discoverable except via its direct link
|
- [ ] The event is not discoverable except via its direct link
|
||||||
|
|
||||||
**Dependencies:** T-4
|
**Dependencies:** T-4
|
||||||
|
|
||||||
**Notes:** Non-guessable tokens (UUIDs) are specified in Ideen.md under security. Expiry date is mandatory per Ideen.md. No registration required per core principles. Per Q-4 resolution: organizer authentication uses the organizer token stored in localStorage on the device where the event was created. The organizer token is separate from the event token — since the event link is designed to be shared in group chats, using the same token for both public access and organizer auth would allow any guest to manage the event.
|
**Notes:** Non-guessable tokens (UUIDs) are specified in Ideen.md under security. Expiry date is mandatory per Ideen.md. No registration required per core principles. Per Q-4 resolution: organizer authentication uses the organizer token stored in localStorage on the device where the event was created. The organizer token is separate from the event token — since the event link is designed to be shared in group chats, using the same token for both public access and organizer auth would allow any guest to manage the event.
|
||||||
|
|
||||||
|
**Addendum (2026-03-04):** Honeypot field removed — overengineered for this project's scope. Expiry date must be in the future at creation time — an event should never exist in an invalid state (resolved during US-1 research).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### US-2: View event landing page
|
### US-2: View event landing page
|
||||||
@@ -78,14 +79,15 @@ The following terms are used consistently across all stories:
|
|||||||
- [ ] The event token, title, and date are also stored in localStorage alongside the RSVP data, so the local event overview (US-7) can display the event and link to it without server contact
|
- [ ] The event token, title, and date are also stored in localStorage alongside the RSVP data, so the local event overview (US-7) can display the event and link to it without server contact
|
||||||
- [ ] If a prior RSVP exists in localStorage for this event, the form pre-fills with the previous choice and name
|
- [ ] If a prior RSVP exists in localStorage for this event, the form pre-fills with the previous choice and name
|
||||||
- [ ] Re-submitting from the same device updates the existing RSVP entry rather than creating a duplicate
|
- [ ] Re-submitting from the same device updates the existing RSVP entry rather than creating a duplicate
|
||||||
- [ ] A honeypot field is present in the RSVP form: hidden from real users; any submission with the field populated is silently discarded server-side
|
|
||||||
- [ ] RSVP submission is not possible after the event's expiry date
|
- [ ] RSVP submission is not possible after the event's expiry date
|
||||||
- [ ] RSVP submission is not possible if the event has been cancelled (US-18) [deferred until US-18 is implemented]
|
- [ ] RSVP submission is not possible if the event has been cancelled (US-18) [deferred until US-18 is implemented]
|
||||||
- [ ] No account, login, or data beyond the optionally entered name is required
|
- [ ] No account, login, or data beyond the optionally entered name is required
|
||||||
|
|
||||||
**Dependencies:** US-2, T-4
|
**Dependencies:** US-2, T-4
|
||||||
|
|
||||||
**Notes:** RSVP flow specified in Ideen.md: "Ich komme" (with name) / "Ich komme nicht" (optional with name). LocalStorage device binding is the explicit duplicate-prevention mechanism — not a hard guarantee, but sufficient against accidental duplicates. Ideen.md acknowledges that malicious spam without accounts is an acceptable risk. Honeypot fields are listed under Ideen.md security measures.
|
**Notes:** RSVP flow specified in Ideen.md: "Ich komme" (with name) / "Ich komme nicht" (optional with name). LocalStorage device binding is the explicit duplicate-prevention mechanism — not a hard guarantee, but sufficient against accidental duplicates. Ideen.md acknowledges that malicious spam without accounts is an acceptable risk.
|
||||||
|
|
||||||
|
**Addendum (2026-03-04):** Honeypot field removed — overengineered for this project's scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -464,3 +466,21 @@ The following terms are used consistently across all stories:
|
|||||||
**Dependencies:** US-1, T-4
|
**Dependencies:** US-1, T-4
|
||||||
|
|
||||||
**Notes:** The overseer identified that using the expiry date as a deletion mechanism (setting it to today or a past date in US-5) was unintuitive and conflated two different actions. US-5 now enforces that the expiry date can only be set to a future date. If the organizer wants the event gone immediately, they use this explicit deletion feature. Unlike cancellation (US-18), which keeps the event visible with a cancellation notice until the expiry date, deletion removes the event entirely and immediately. This is the organizer's "nuclear option" — useful when the event was created by mistake, contains wrong information, or is no longer needed at all. The deletion behavior is identical to what US-12 does automatically after expiry, but triggered manually and immediately by the organizer.
|
**Notes:** The overseer identified that using the expiry date as a deletion mechanism (setting it to today or a past date in US-5) was unintuitive and conflated two different actions. US-5 now enforces that the expiry date can only be set to a future date. If the organizer wants the event gone immediately, they use this explicit deletion feature. Unlike cancellation (US-18), which keeps the event visible with a cancellation notice until the expiry date, deletion removes the event entirely and immediately. This is the organizer's "nuclear option" — useful when the event was created by mistake, contains wrong information, or is no longer needed at all. The deletion behavior is identical to what US-12 does automatically after expiry, but triggered manually and immediately by the organizer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### US-20: 404 page
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
**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. This is a small UX story but important for polish. Note: This story has no structural dependencies but requires the frontend scaffold from T-4 (which includes T-1) to be practically implementable.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user