---
date: 2026-03-04T21:41:13+00:00
git_commit: 91e566efea0cbf53ba06a29b63317b7435609bd8
branch: master
topic: "US-1: Create an Event"
tags: [plan, us-1, event-creation, full-stack]
status: approved
---
# US-1: Create an Event — Implementation Plan
## Overview
Implement the first user story end-to-end: an organizer creates an event with title, description, date/time, location, and expiry date. The server stores the event, returns event token + organizer token. The frontend stores tokens in localStorage and redirects to a stub event page. This is the first vertical slice through all layers of the hexagonal architecture.
Additionally: remove the scaffolding health endpoint from the OpenAPI spec (replaced by Spring Actuator), establish RFC 9457 error handling, set the app's visual design foundation (mobile-first, app-native feel), and clean up Vue scaffold defaults.
## Current State Analysis
- All setup tasks (T-1 through T-5) complete
- Hexagonal architecture skeleton with ArchUnit enforcement — all business packages empty
- OpenAPI spec contains only a scaffolding `/health` endpoint (to be removed)
- Liquibase with empty baseline migration
- Testcontainers PostgreSQL for integration tests
- Vue 3 SPA with placeholder routes and openapi-fetch client
- Dockerfile HEALTHCHECK correctly uses `/actuator/health` (not the custom endpoint)
- No domain code, no persistence, no controllers exist yet
### Key Discoveries:
- `WebConfig.java:19` — API prefix `/api` applied to all `@RestController` beans
- `WebConfig.java:23-39` — SPA fallback routes non-API requests to `index.html`
- `application.properties:4` — JPA `ddl-auto=validate` (Liquibase manages schema)
- OpenAPI generator: `interfaceOnly=true`, `useBeanValidation=true`, packages `de.fete.adapter.in.web.api` / `.model`
- Frontend types generated via `npm run generate:api` into `src/api/schema.d.ts`
- Dockerfile HEALTHCHECK (`Dockerfile:25-26`) uses `/actuator/health` — safe to remove custom health endpoint
## Desired End State
After this plan is complete:
1. `POST /api/events` accepts a JSON body with title, description, dateTime, location, expiryDate
2. Server validates input (Bean Validation), creates the event with two UUIDs (event token, organizer token), persists to PostgreSQL, returns both tokens
3. Error responses follow RFC 9457 Problem Details format with field-level validation errors
4. Frontend shows a mobile-first event creation form at `/create`
5. On success, localStorage stores organizer token + event metadata, browser redirects to `/events/:token` (stub page)
6. Root page `/` shows a minimal landing with "Create Event" button
7. The scaffolding health endpoint is removed from the OpenAPI spec
8. Vue scaffold defaults are cleaned up
9. All tests pass (backend unit + integration, frontend unit, ArchUnit, Checkstyle)
### UI Mockups
#### Root Page `/` (Mobile)
```
┌─────────────────────────────┐
│ │
│ ┌─────────────────────┐ │
│ │ fete │ │
│ └─────────────────────┘ │
│ │
│ │
│ No events yet. │
│ Create your first one! │
│ │
│ ┌─────────────────────┐ │
│ │ + Create Event │ │
│ └─────────────────────┘ │
│ │
│ │
└─────────────────────────────┘
```
#### Create Event `/create` (Mobile)
```
┌─────────────────────────────┐
│ ← Create │
│ │
│ ┌─────────────────────────┐│
│ │ Title * ││
│ │ ││
│ └─────────────────────────┘│
│ │
│ ┌─────────────────────────┐│
│ │ Description ││
│ │ ││
│ │ ││
│ └─────────────────────────┘│
│ │
│ ┌─────────────────────────┐│
│ │ 📅 Date & Time * ││
│ └─────────────────────────┘│
│ │
│ ┌─────────────────────────┐│
│ │ 📍 Location ││
│ └─────────────────────────┘│
│ │
│ ┌─────────────────────────┐│
│ │ 📆 Expiry Date * ││
│ └─────────────────────────┘│
│ │
│ ┌─────────────────────────┐│
│ │ Create Event ││
│ └─────────────────────────┘│
│ │
└─────────────────────────────┘
* = required
Card-style inputs, rounded corners,
generous padding, gradient background
```
#### Event Stub `/events/:token` (Mobile)
```
┌─────────────────────────────┐
│ ← fete │
│ │
│ │
│ ✓ Event created! │
│ │
│ Share this link: │
│ ┌─────────────────────────┐│
│ │ https://…/events/abc123 ││
│ │ 📋 Copy ││
│ └─────────────────────────┘│
│ │
│ │
└─────────────────────────────┘
```
#### Desktop Layout
```
┌──────────────────────────────────────────────┐
│ ┌──────────────────┐ │
│ │ │ │
│ │ (mobile view │ │
│ │ centered, │ │
│ │ max ~480px) │ │
│ │ │ │
│ └──────────────────┘ │
│ │
│ ← gradient background fills full width → │
└──────────────────────────────────────────────┘
```
## What We're NOT Doing
- Full event page (US-2) — only a stub with "Event created" confirmation
- RSVP functionality (US-3)
- Organizer view / event editing (US-4, US-5)
- Dark/light mode toggle (US-17) — but we design CSS with future theming in mind
- Concrete color palette selection — researched during implementation with browser tools
- PWA manifest / service worker (US-14)
- Event image or color theme (US-15, US-16)
## Design Principles (Persistent — Applies to All Future Stories)
These design decisions apply to US-1 and all subsequent frontend work:
- **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
- **Gradient backgrounds** as primary design language (subtle 2-3 color gradients)
- **Card-style form fields** — rounded corners, generous padding, elevated look
- **Typography: Sora** — contemporary geometric sans-serif with slightly rounded terminals. Self-hosted WOFF2, OFL 1.1 licensed. Source: github.com/sora-xor/sora-font. No Google Fonts CDN.
- **Generous whitespace** — elements breathe, nothing cramped
- **WCAG AA contrast** as baseline for all color choices
### Color Palette: Electric Dusk
Chosen for best balance of style, broad appeal, and accessibility (white text readable across almost the entire gradient).
| Role | Hex | Description |
|------|-----|-------------|
| Gradient Start | `#F06292` | Pink |
| Gradient Mid | `#AB47BC` | Purple |
| Gradient End | `#5C6BC0` | Indigo blue |
| Accent (CTAs/buttons) | `#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
## Implementation Approach
API-first, TDD, inside-out through the hexagonal layers. Seven phases:
1. **OpenAPI Spec & Cleanup** — define the contract, remove scaffolding health endpoint
2. **Database Migration** — create the `events` table
3. **Domain & Ports** — `Event` model, `CreateEventUseCase`, `EventRepository`
4. **Application Service** — `EventService` implementing the use case
5. **Persistence Adapter** — JPA entity, Spring Data repository
6. **Web Adapter & Error Handling** — Controller, `GlobalExceptionHandler`
7. **Frontend** — design foundation, form, localStorage, routing, stub page
---
## Phase 1: OpenAPI Spec & Cleanup
### Overview
Define the `POST /events` contract in the OpenAPI spec. Remove the scaffolding `/health` endpoint and `HealthResponse` schema. Verify that nothing depends on the removed endpoint. Regenerate backend interfaces and frontend types.
### Changes Required:
#### [x] 1.1 Remove health endpoint from OpenAPI spec
**File**: `backend/src/main/resources/openapi/api.yaml`
**Changes**: Remove the `/health` path and `HealthResponse` schema. Add the `POST /events` path with request/response schemas and RFC 9457 error response schemas.
```yaml
openapi: 3.1.0
info:
title: fete API
description: Privacy-focused event announcements and RSVPs
version: 0.1.0
license:
name: GPL-3.0-or-later
identifier: GPL-3.0-or-later
servers:
- url: /api
paths:
/events:
post:
operationId: createEvent
summary: Create a new event
tags:
- events
requestBody:
required: true
content:
application/json:
schema:
$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:
schemas:
CreateEventRequest:
type: object
required:
- title
- dateTime
- expiryDate
properties:
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
```
#### [x] 1.2 Remove health endpoint test reference
**File**: `backend/src/test/java/de/fete/config/WebConfigTest.java`
**Changes**: Verify that tests only reference `/actuator/health`, not `/api/health`. Remove or update any test that calls the generated `HealthApi` interface. The test `apiPrefixNotAccessibleWithoutIt` may need to be adapted to use the new `/events` endpoint instead.
#### [x] 1.3 Regenerate backend and frontend types
**Action**: Run `cd backend && ./mvnw compile` and `cd frontend && npm run generate:api` to verify generation succeeds with the new spec. The old `HealthApi.java` and `HealthResponse.java` in `target/` will be replaced by `EventsApi.java`, `CreateEventRequest.java`, `CreateEventResponse.java`, etc.
### Success Criteria:
#### Automated Verification:
- [ ] `cd backend && ./mvnw compile` succeeds — generates `EventsApi` interface, request/response models
- [ ] `cd frontend && npm run generate:api` succeeds — `schema.d.ts` contains event types
- [ ] `cd backend && ./mvnw test` passes — no test references removed health endpoint
- [ ] No `HealthApi.java` or `HealthResponse.java` in generated output
#### Manual Verification:
- [ ] OpenAPI spec is valid (no syntax errors)
- [ ] Generated `EventsApi` interface has `createEvent` method with correct signature
**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: Database Migration
### Overview
Create the `events` table via a Liquibase migration. The table stores all event fields plus both UUID tokens and a `created_at` audit timestamp.
### Changes Required:
#### [x] 2.1 Create Liquibase migration
**File**: `backend/src/main/resources/db/changelog/001-create-events-table.xml`
**Changes**: New migration file.
```xml
```
#### [x] 2.2 Include migration in master changelog
**File**: `backend/src/main/resources/db/changelog/db.changelog-master.xml`
**Changes**: Add include for the new migration file after the baseline.
```xml
```
### Success Criteria:
#### Automated Verification:
- [ ] `cd backend && ./mvnw test` passes — Testcontainers spins up PostgreSQL, Liquibase runs migration, Hibernate validates schema
- [ ] Index on `event_token` exists (primary lookup path for public access)
- [ ] Index on `expiry_date` exists (cleanup job query path, US-12)
#### Manual Verification:
- [ ] Migration file uses correct PostgreSQL types (`timestamptz`, `date`, `uuid`, `bigserial`)
- [ ] Column constraints match domain rules (title not null, description nullable, etc.)
**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: Domain Model & Ports
### Overview
Create the `Event` domain entity and the inbound/outbound port interfaces. The domain layer has zero dependencies on Spring or adapters (enforced by ArchUnit).
### Changes Required:
#### [x] 3.1 Event domain model
**File**: `backend/src/main/java/de/fete/domain/model/Event.java`
**Changes**: New file. Plain Java class — no JPA annotations, no Spring dependencies.
```java
package de.fete.domain.model;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
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;
// Constructor, getters, setters (or builder pattern if warranted)
}
```
#### [x] 3.2 CreateEventUseCase inbound port
**File**: `backend/src/main/java/de/fete/domain/port/in/CreateEventUseCase.java`
**Changes**: New file. Interface with a command object.
```java
package de.fete.domain.port.in;
import de.fete.domain.model.Event;
import java.time.LocalDate;
import java.time.OffsetDateTime;
public interface CreateEventUseCase {
Event createEvent(CreateEventCommand command);
record CreateEventCommand(
String title,
String description,
OffsetDateTime dateTime,
String location,
LocalDate expiryDate
) {}
}
```
#### [x] 3.3 EventRepository outbound port
**File**: `backend/src/main/java/de/fete/domain/port/out/EventRepository.java`
**Changes**: New file. Interface — only the `save` method needed for US-1. `findByEventToken` added for the stub page route (minimal, but needed for redirect verification in later stories).
```java
package de.fete.domain.port.out;
import de.fete.domain.model.Event;
import java.util.Optional;
import java.util.UUID;
public interface EventRepository {
Event save(Event event);
Optional findByEventToken(UUID eventToken);
}
```
### Success Criteria:
#### Automated Verification:
- [ ] `cd backend && ./mvnw test` passes — ArchUnit validates:
- `Event` in `domain.model` has no Spring/adapter dependencies
- `CreateEventUseCase` and `EventRepository` are interfaces
- No dependency violations between layers
- [ ] `cd backend && ./mvnw checkstyle:check` passes
#### Manual Verification:
- [ ] Domain model fields match the database schema from Phase 2
- [ ] `CreateEventCommand` record contains only the fields the organizer provides (no tokens, no id, no createdAt)
**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: Application Service
### Overview
Implement `EventService` — the use case implementation that validates the expiry date, generates UUID tokens, builds the domain model, and delegates to the repository.
### Changes Required:
#### [x] 4.1 EventService
**File**: `backend/src/main/java/de/fete/application/service/EventService.java`
**Changes**: New file. Implements `CreateEventUseCase`. Spring `@Service` annotation.
```java
package de.fete.application.service;
import de.fete.domain.model.Event;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.out.EventRepository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.springframework.stereotype.Service;
@Service
public class EventService implements CreateEventUseCase {
private final EventRepository eventRepository;
public EventService(EventRepository eventRepository) {
this.eventRepository = eventRepository;
}
@Override
public Event createEvent(CreateEventCommand command) {
if (!command.expiryDate().isAfter(LocalDate.now())) {
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());
return eventRepository.save(event);
}
}
```
#### [x] 4.2 ExpiryDateInPastException
**File**: `backend/src/main/java/de/fete/application/service/ExpiryDateInPastException.java`
**Changes**: New file. Domain-level exception for the business rule "expiry date must be in the future".
```java
package de.fete.application.service;
import java.time.LocalDate;
public class ExpiryDateInPastException extends RuntimeException {
private final LocalDate expiryDate;
public ExpiryDateInPastException(LocalDate expiryDate) {
super("Expiry date must be in the future: " + expiryDate);
this.expiryDate = expiryDate;
}
public LocalDate getExpiryDate() {
return expiryDate;
}
}
```
#### [x] 4.3 Unit tests for EventService
**File**: `backend/src/test/java/de/fete/application/service/EventServiceTest.java`
**Changes**: New file. Unit tests with a mocked `EventRepository`.
Test cases:
- Happy path: valid command → event saved with generated tokens, createdAt set
- Expiry date today → `ExpiryDateInPastException`
- Expiry date in the past → `ExpiryDateInPastException`
- Expiry date tomorrow → succeeds
- Event token and organizer token are different UUIDs
- Repository `save` is called exactly once
### Success Criteria:
#### Automated Verification:
- [ ] `cd backend && ./mvnw test` passes — all `EventServiceTest` tests green
- [ ] `cd backend && ./mvnw checkstyle:check` passes
- [ ] ArchUnit: `EventService` in `application.service` may depend on `domain.model` and `domain.port` but not on adapters
#### Manual Verification:
- [ ] Business rule enforced: expiry date must be strictly after today
- [ ] UUID generation uses `UUID.randomUUID()` (v4, non-guessable)
**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 5: Persistence Adapter
### Overview
Implement the JPA entity and Spring Data repository that fulfill the `EventRepository` outbound port. The adapter translates between the domain model and the JPA entity.
### Changes Required:
#### [x] 5.1 JPA Entity
**File**: `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java`
**Changes**: New file. JPA entity mapping to the `events` table.
```java
package de.fete.adapter.out.persistence;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
@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;
// Getters and setters
}
```
#### [x] 5.2 Spring Data Repository
**File**: `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaRepository.java`
**Changes**: New file. Spring Data JPA interface.
```java
package de.fete.adapter.out.persistence;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface EventJpaRepository extends JpaRepository {
Optional findByEventToken(UUID eventToken);
}
```
#### [x] 5.3 Persistence Adapter (Port Implementation)
**File**: `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java`
**Changes**: New file. Implements `EventRepository` port, translates between domain model and JPA entity.
```java
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;
@Repository
public class EventPersistenceAdapter implements EventRepository {
private final EventJpaRepository jpaRepository;
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 findByEventToken(UUID eventToken) {
return jpaRepository.findByEventToken(eventToken).map(this::toDomain);
}
private EventJpaEntity toEntity(Event event) { /* field mapping */ }
private Event toDomain(EventJpaEntity entity) { /* field mapping */ }
}
```
#### [x] 5.4 Integration test
**File**: `backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java`
**Changes**: New file. Integration test with Testcontainers.
Test cases:
- Save event → returns event with generated ID
- Save event → `findByEventToken` returns same event
- `findByEventToken` with unknown UUID → empty Optional
- All fields round-trip correctly (especially `OffsetDateTime` ↔ `timestamptz` and `LocalDate` ↔ `date`)
### Success Criteria:
#### Automated Verification:
- [ ] `cd backend && ./mvnw test` passes — persistence integration tests green
- [ ] `cd backend && ./mvnw checkstyle:check` passes
- [ ] ArchUnit: persistence adapter does not depend on web adapter
- [ ] Hibernate schema validation passes (JPA entity matches Liquibase migration)
#### Manual Verification:
- [ ] JPA entity column mappings match Liquibase migration exactly
- [ ] Domain ↔ JPA entity mapping is complete (no fields missed)
**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 6: Web Adapter & Error Handling
### Overview
Implement the REST controller (generated `EventsApi` interface) and the `GlobalExceptionHandler` for RFC 9457 Problem Details. Remove or update any remaining health endpoint references in tests.
### Changes Required:
#### [x] 6.1 GlobalExceptionHandler
**File**: `backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java`
**Changes**: New file. Handles all exceptions consistently as RFC 9457 Problem Details.
```java
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.*;
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;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity