22 Commits

Author SHA1 Message Date
Renovate Bot
ac7f508715 Update dependency maven to v3.9.13
All checks were successful
CI / backend-test (push) Successful in 56s
CI / frontend-test (push) Successful in 18s
CI / build-and-publish (push) Has been skipped
2026-03-06 13:02:16 +00:00
ffea279b54 Update CLAUDE.md with build commands and project conventions
All checks were successful
CI / backend-test (push) Successful in 1m3s
CI / frontend-test (push) Successful in 19s
CI / build-and-publish (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:58:20 +01:00
4cfac860aa Add US-20 (404 page) to user stories
Catch-all route for unknown paths with link back to home.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:58:10 +01:00
e3ca613210 Add agent research and implementation plan docs for US-1
Research reports on datetime handling, RFC 9457, font selection.
Implementation plans for US-1 create event and post-review fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:57:44 +01:00
14f11875a4 Add Vite dev proxy for backend API
Proxy /api requests to localhost:8080 during development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:57:20 +01:00
a029e951b8 Add event stub page with clipboard sharing
Post-creation confirmation page showing shareable event URL with
copy-to-clipboard and fallback feedback on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:57:10 +01:00
84feeb9997 Implement event creation frontend (EventCreateView)
Form with client-side validation, server error handling, aria-invalid/
aria-describedby for a11y, localStorage persistence via useEventStorage
composable. Routes for /create and /events/:token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:59 +01:00
f3d4b5fa17 Add REST controller with RFC 9457 error handling
EventController for POST /events, GlobalExceptionHandler mapping
validation and business exceptions to problem+json responses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:48 +01:00
c80074093c Add persistence layer with Liquibase migration
JPA entity, repository, persistence adapter for events. Liquibase
changelog creates the events table with BIGSERIAL ID and UUID tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:39 +01:00
830ca55f20 Implement event domain model and application service
Add Event entity, CreateEventCommand, ports (CreateEventUseCase,
EventRepository), and EventService with Clock injection for
deterministic testing. Expiry date must be in the future.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:28 +01:00
eeadaf58c7 Add OpenAPI spec for POST /events endpoint
Define CreateEventRequest, CreateEventResponse, ProblemDetail, and
ValidationProblemDetail schemas. RFC 9457 problem details for errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:56:17 +01:00
387445e089 Remove Vue scaffolding and add design system foundation
Strip default Vue components (HelloWorld, TheWelcome, icons, AboutView),
base.css, and placeholder assets. Add Sora font (self-hosted WOFF2),
Electric Dusk color palette, and design system spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:55:53 +01:00
c2bbb78b7b Add OpenAPI spec validation hook (Redocly CLI)
All checks were successful
CI / backend-test (push) Successful in 50s
CI / frontend-test (push) Successful in 17s
CI / build-and-publish (push) Has been skipped
PostToolUse hook triggers on openapi/*.yaml edits and runs
redocly lint with the recommended ruleset (security-defined
disabled since endpoints are intentionally public).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:01:17 +01:00
91e566efea Remove honeypot fields from US-1 and US-3
Honeypot spam protection is overengineered for this project's scope.
Removed the acceptance criteria from both stories and added addenda
documenting the decision. Updated implementation order and review
findings accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:24:23 +01:00
b8421274b4 Add API-first development methodology to project statutes
The OpenAPI spec is the single source of truth for the REST API
contract. Endpoints and schemas must be defined in the spec before
writing implementation code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:08:19 +01:00
747ed18945 Replace implementation phases with sequential implementation order
Rework the implementation roadmap from parallelizable phases to a strict
sequential order optimized for earliest usable increments. Key changes:
- All 20 user stories in a single sequential queue (no parallelization)
- Progress tracker at the top for status tracking
- US-17 (dark mode) moved from Phase 5 to Increment 4 (before US-15)
- US-14 (PWA) moved to last position
- Deferred AC activation schedule added

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:02:13 +01:00
e9110ea143 Add recommended preset to Renovate config
All checks were successful
CI / backend-test (push) Successful in 49s
CI / frontend-test (push) Successful in 18s
CI / build-and-publish (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:43:11 +01:00
bd84f4e355 Merge pull request 'Configure Renovate' (#1) from renovate/configure into master
All checks were successful
CI / backend-test (push) Successful in 51s
CI / frontend-test (push) Successful in 17s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #1
2026-03-04 21:41:58 +01:00
23b264e66e T-4: add JPA, Liquibase, Testcontainers, and deployment docs
All checks were successful
CI / backend-test (push) Successful in 1m4s
CI / frontend-test (push) Successful in 18s
CI / build-and-publish (push) Has been skipped
Set up development infrastructure for TDD: JPA + Liquibase for
database migrations, Testcontainers for integration tests against
real PostgreSQL, profile-based configuration (prod/local), and
README deployment documentation with docker-compose example.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:40:06 +01:00
cb0bcad145 Add release skill for SemVer tagging workflow
Project-specific skill that validates version, checks for clean
working tree and pushed commits, then creates and pushes a SemVer
tag to trigger the CI/CD pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:35:50 +01:00
e8184be12f T-3: mark CI/CD pipeline complete, update spec and plan
All checks were successful
CI / backend-test (push) Successful in 46s
CI / frontend-test (push) Successful in 17s
CI / build-and-publish (push) Has been skipped
All manual verification passed:
- Branch push: tests only, no image build
- Non-SemVer tag: tests only, no image build
- SemVer tag (0.0.1): all jobs green, 4 tags in Gitea registry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:27:57 +01:00
Renovate Bot
f5df2d7290 Add renovate.json 2026-03-04 02:01:38 +00:00
80 changed files with 6269 additions and 671 deletions

View 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

View File

@@ -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
} }
] ]
} }

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

3
.gitignore vendored
View File

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

View File

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

View File

@@ -21,6 +21,7 @@ FROM eclipse-temurin:25-jre-alpine
WORKDIR /app WORKDIR /app
COPY --from=backend-build /app/backend/target/*.jar app.jar COPY --from=backend-build /app/backend/target/*.jar app.jar
EXPOSE 8080 EXPOSE 8080
ENV SPRING_PROFILES_ACTIVE=prod
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1 CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]

View File

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

View File

@@ -1,3 +1,3 @@
wrapperVersion=3.3.4 wrapperVersion=3.3.4
distributionType=only-script distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip

View File

@@ -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
View File

@@ -0,0 +1,5 @@
extends:
- recommended
rules:
security-defined: off

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -1,6 +1,8 @@
package de.fete.config; package de.fete.config;
import java.io.IOException; import java.io.IOException;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
@@ -14,6 +16,11 @@ import org.springframework.web.servlet.resource.PathResourceResolver;
@Configuration @Configuration
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
@Bean
Clock clock() {
return Clock.systemDefaultZone();
}
@Override @Override
public void configurePathMatch(PathMatchConfigurer configurer) { public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class)); configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));

View File

@@ -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
) {}

View 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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

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

View File

@@ -0,0 +1,4 @@
# Database (required)
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}

View File

@@ -1,4 +1,12 @@
spring.application.name=fete 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.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never management.endpoint.health.show-details=never

View 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>

View File

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

View File

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

View File

@@ -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
responses: requestBody:
"200": required: true
description: Service is healthy
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/HealthResponse" $ref: "#/components/schemas/CreateEventRequest"
responses:
"201":
description: Event created successfully
content:
application/json:
schema:
$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
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 type: string
example: UP

View File

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

View 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);
}
}

View 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");
}
}

View File

@@ -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"));
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -4,14 +4,17 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.fete.TestcontainersConfig;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.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 WebConfigTest { class WebConfigTest {
@Autowired @Autowired
@@ -26,8 +29,8 @@ class WebConfigTest {
@Test @Test
void apiPrefixNotAccessibleWithoutIt() throws Exception { void apiPrefixNotAccessibleWithoutIt() throws Exception {
// /health without /api prefix should not resolve to the API endpoint // /events without /api prefix should not resolve to the API endpoint
mockMvc.perform(get("/health")) mockMvc.perform(get("/events"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
} }

View File

@@ -218,10 +218,10 @@ jobs:
- [x] Authentication uses `secrets.REGISTRY_TOKEN` (not the built-in token) - [x] Authentication uses `secrets.REGISTRY_TOKEN` (not the built-in token)
#### Manual Verification: #### Manual Verification:
- [ ] Push a commit to a branch → pipeline runs `backend-test` and `frontend-test` only — no image build - [x] Push a commit to a branch → pipeline runs `backend-test` and `frontend-test` only — no image build
- [ ] Push a SemVer tag → pipeline runs all three jobs, image appears in Gitea container registry with 4 tags - [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 - [ ] Break a test intentionally → pipeline fails, `build-and-publish` does not run (skipped — guaranteed by `needs` dependency, verified implicitly)
- [ ] Push a non-SemVer tag → pipeline runs tests only, no image build - [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. **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.
@@ -251,7 +251,11 @@ jobs:
The following must be configured in Gitea **before** the pipeline can publish images: 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 1. **Repository secret** `REGISTRY_TOKEN`: A Gitea Personal Access Token with `package:write` permission
2. **Buildah** must be installed on the runner (standard on most Linux runners) 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 ## References

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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/)

View 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?

View 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)

View 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 (ThinBlack) | ✅ Recommended |
| Plus Jakarta Sans | OFL-1.1 | Geometric, modern | 7 (ExtraLightExtraBold) | ✅ Recommended |
| Outfit | OFL-1.1 | Geometric | 9 (ThinBlack) | ✅ Recommended |
| Space Grotesk | OFL-1.1 | Neo-grotesque, distinctive | 5 (LightBold) | ✅ Recommended |
| Manrope | OFL-1.1 | Geometric, humanist | 7 (ExtraLightExtraBold) | ✅ Recommended |
| DM Sans | OFL-1.1 | Geometric, low-contrast | 9 (ThinBlack) | ✅ Recommended |
| Sora | OFL-1.1 | Geometric | 8 (ThinExtraBold) | ✅ 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 1416px 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 (ThinBlack)
- 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 1214px
- Full weight range (ThinBlack)
- 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, 510 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 1214px)
- 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)

View File

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

View File

@@ -0,0 +1,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
View File

@@ -37,3 +37,4 @@ __screenshots__/
# Vite # Vite
*.timestamp-*-*.mjs *.timestamp-*-*.mjs
.rodney/

View File

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

View File

@@ -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" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView /> <RouterView />
</div>
</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>

View File

@@ -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;
}

Binary file not shown.

View File

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

View File

@@ -1,35 +1,181 @@
@import './base.css'; @font-face {
font-family: 'Sora';
#app { src: url('@/assets/fonts/Sora-Variable.woff2') format('woff2');
max-width: 1280px; font-weight: 100 800;
margin: 0 auto; font-display: swap;
padding: 2rem; font-style: normal;
font-weight: normal;
} }
a, :root {
.green { /* Colors: Electric Dusk */
text-decoration: none; --color-gradient-start: #f06292;
color: hsla(160, 100%, 37%, 1); --color-gradient-mid: #ab47bc;
transition: 0.4s; --color-gradient-end: #5c6bc0;
padding: 3px; --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;
} }
@media (hover: hover) { *,
a:hover { *::before,
background-color: hsla(160, 100%, 37%, 0.2); *::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;
} }
@media (min-width: 1024px) {
body { body {
display: flex; min-height: 100vh;
place-items: center; background: var(--gradient-primary);
} }
#app { #app {
display: grid; min-height: 100vh;
grid-template-columns: 1fr 1fr; display: flex;
padding: 0 2rem; flex-direction: column;
align-items: center;
} }
.app-container {
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;
}
.btn-primary:hover {
opacity: 0.92;
}
.btn-primary:active {
transform: scale(0.98);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 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;
} }

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>

View File

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

View File

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

View File

@@ -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')
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View 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')
})
})

View 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 }
}

View File

@@ -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'),
}, },
], ],
}) })

View File

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

View File

@@ -0,0 +1,258 @@
<template>
<main class="create">
<header class="create__header">
<RouterLink to="/" class="create__back" aria-label="Back to home">&larr;</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 &amp; 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>

View File

@@ -0,0 +1,132 @@
<template>
<main class="stub">
<header class="stub__header">
<RouterLink to="/" class="stub__back" aria-label="Back to home">&larr;</RouterLink>
<span class="stub__brand">fete</span>
</header>
<div class="stub__content">
<p class="stub__check">&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>

View File

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

View 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)
})
})

View 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('/')
})
})

View File

@@ -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
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"]
}

View File

@@ -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
View 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.851rem |
| Labels | 600700| 0.80.9rem |
| Headlines | 700800| 1.21.6rem |
| Buttons | 700800| 1rem |
| Small/meta text | 400500| 0.750.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 (700800)
- 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)

View File

@@ -1,160 +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-5: API-first tooling setup | T-1 | OpenAPI spec, codegen plugins, generated types |
| 3 | T-2: Docker deployment setup | T-1, T-5 | Multi-stage Dockerfile — builds backend + frontend into one container |
| 4 | T-4: Development infrastructure | T-2, T-5 | Migrations, DB wiring, router, test infra, docker-compose docs — gates all user stories |
| 4* | 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 --> T5(["T-5: API-First Tooling"]):::infra
T1 --> T2(["T-2: Docker"]):::infra
T5 --> T2
T2 --> T4(["T-4: Dev Infra + DB"]):::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).

View File

@@ -43,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
@@ -81,15 +81,15 @@
**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. **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
- [ ] App connects to external PostgreSQL via environment variable (e.g. `DATABASE_URL` or Spring-native `SPRING_DATASOURCE_*`) - [x] App connects to external PostgreSQL via environment variable (e.g. `DATABASE_URL` or Spring-native `SPRING_DATASOURCE_*`)
- [ ] All runtime configuration via environment variables: database connection, optional Unsplash API key, optional max active events - [x] All runtime configuration via environment variables: database connection, optional Unsplash API key, optional max active events
- [ ] SPA router is configured in the Vue frontend (Vue Router) so pages can be navigated by URL path - [x] SPA router is configured in the Vue frontend (Vue Router) so pages can be navigated by URL path
- [ ] 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] 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
- [ ] Frontend test infrastructure is set up: Vitest with @vue/test-utils configured and a sample test runs successfully - [x] Frontend test infrastructure is set up: Vitest with @vue/test-utils configured and a sample test runs successfully
- [ ] Both test suites (backend and frontend) can be executed via their respective build tools (`mvn test` and `npm test` / `npx vitest`) - [x] Both test suites (backend and frontend) can be executed via their respective build tools (`mvn test` and `npm test` / `npx vitest`)
- [ ] README documents deployment setup with a docker-compose example (app + postgres) - [x] README documents deployment setup with a docker-compose example (app + postgres)
- [ ] Container starts and responds to health checks with a running PostgreSQL (migrations run on startup) - [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

View File

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