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

#### [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
<include file="db/changelog/001-create-events-table.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<Event> 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<EventJpaEntity, Long> {

    Optional<EventJpaEntity> 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<Event> 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<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);
    }

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

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

#### [x] 6.2 EventController
**File**: `backend/src/main/java/de/fete/adapter/in/web/EventController.java`
**Changes**: New file. Implements generated `EventsApi` interface.

```java
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.Event;
import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateEventUseCase.CreateEventCommand;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class EventController implements EventsApi {

    private final CreateEventUseCase createEventUseCase;

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

#### [x] 6.3 Update WebConfigTest
**File**: `backend/src/test/java/de/fete/config/WebConfigTest.java`
**Changes**: Replace health endpoint references with the new `/api/events` endpoint. Keep the `/actuator/health` test. Adapt `apiPrefixNotAccessibleWithoutIt` to use `/events` instead of `/health`.

#### [x] 6.4 Integration test for EventController
**File**: `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
**Changes**: New file. Full Spring Boot integration test with MockMvc + Testcontainers.

Test cases:
- POST valid event → 201, response contains eventToken, organizerToken, title, dateTime, expiryDate
- POST missing title → 400, Problem Details with fieldErrors
- POST missing dateTime → 400, Problem Details with fieldErrors
- POST missing expiryDate → 400, Problem Details with fieldErrors
- POST expiry date in the past → 400, Problem Details with "expiry-date-in-past" type
- POST expiry date today → 400
- POST with all optional fields null → 201 (description and location are optional)
- Response Content-Type for errors is `application/problem+json`

#### [x] 6.5 Remove HealthController test (if any exists)
**File**: Verify no test file references a `HealthController` or `HealthApi` implementation. The only health tests should reference `/actuator/health`.

### Success Criteria:

#### Automated Verification:
- [ ] `cd backend && ./mvnw test` passes — all controller integration tests green
- [ ] `cd backend && ./mvnw checkstyle:check` passes
- [ ] `cd backend && ./mvnw verify` passes (full verification including SpotBugs)
- [ ] ArchUnit: web adapter does not depend on persistence adapter
- [ ] Error responses use `application/problem+json` content type

#### Manual Verification:
- [ ] `POST /api/events` with valid body returns 201 with both tokens
- [ ] Validation errors return field-level details in RFC 9457 format
- [ ] No `/api/health` endpoint exists anymore (404)
- [ ] `/actuator/health` still works

**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 7: Frontend

### Overview
Establish the visual design foundation (mobile-first app feel, gradients, card-style inputs, self-hosted font). Clean up Vue scaffold defaults. Implement the event creation form, localStorage composable, routing, and stub event page.

### Changes Required:

#### [x] 7.1 Clean up Vue scaffold defaults
**Files to remove**:
- `frontend/src/components/HelloWorld.vue`
- `frontend/src/components/TheWelcome.vue`
- `frontend/src/components/WelcomeItem.vue`
- `frontend/src/components/icons/*.vue` (all icon components)
- `frontend/src/views/AboutView.vue`
- `frontend/src/assets/base.css` (replaced by our own styles)
- `frontend/src/assets/logo.svg` (if exists)
- `frontend/src/components/__tests__/HelloWorld.spec.ts`

**Files to update**:
- `frontend/src/App.vue` — strip to minimal app shell
- `frontend/src/views/HomeView.vue` — replace with minimal landing (see mockup)
- `frontend/src/router/index.ts` — remove `/about` route
- `frontend/src/assets/main.css` — replace with our design foundation

#### [x] 7.2 Self-hosted font: Sora
**Directory**: `frontend/src/assets/fonts/`
**Changes**: Download Sora font from github.com/sora-xor/sora-font as WOFF2 files. Include weights: 400 (Regular), 500 (Medium), 600 (SemiBold), 700 (Bold), 800 (ExtraBold). Reference via `@font-face` in CSS. No external CDN. License: OFL 1.1.

#### [x] 7.3 Design foundation CSS (per `spec/design-system.md`)
**File**: `frontend/src/assets/main.css`
**Changes**: Establish the design system:
- CSS custom properties for colors, gradients, spacing, border-radius, font
- Mobile-first base styles
- Gradient background
- Card-style input components
- Desktop centered-column layout (max-width ~480px)
- Responsive breakpoints
- WCAG AA contrast (verified during implementation with color tools)

#### [x] 7.4 App shell component
**File**: `frontend/src/App.vue`
**Changes**: Minimal app shell with `<router-view>`. Gradient background wrapper. Centered column for desktop.

#### [x] 7.5 Home page (minimal landing)
**File**: `frontend/src/views/HomeView.vue`
**Changes**: Empty state with "Create Event" button linking to `/create`. Placeholder for future US-7 event overview.

#### [x] 7.6 Event creation form
**File**: `frontend/src/views/EventCreateView.vue`
**Changes**: New file. Form with card-style inputs for all fields:
- Title (required, text input)
- Description (optional, textarea)
- Date & Time (required, datetime-local input)
- Location (optional, text input)
- Expiry Date (required, date input, min=tomorrow)

Client-side validation:
- Required fields enforced (HTML5 `required` + JS check)
- Expiry date must be in the future (JS check)
- Show validation errors inline on the card fields

On submit:
- Call `api.POST('/events', { body })` via openapi-fetch client
- On success: store tokens in localStorage, redirect to `/events/:token`
- On error: display field errors from RFC 9457 response

#### [x] 7.7 localStorage composable
**File**: `frontend/src/composables/useEventStorage.ts`
**Changes**: New file. Composable for managing event data in localStorage.

```typescript
// Stores per event token:
// - organizerToken (if created from this device)
// - title, dateTime, expiryDate (for local overview, US-7)

interface StoredEvent {
  eventToken: string
  organizerToken?: string
  title: string
  dateTime: string
  expiryDate: string
}

export function useEventStorage() {
  function saveCreatedEvent(event: StoredEvent): void { /* ... */ }
  function getStoredEvents(): StoredEvent[] { /* ... */ }
  function getOrganizerToken(eventToken: string): string | undefined { /* ... */ }
  // ...
}
```

Storage key pattern: `fete:events` → JSON array of `StoredEvent`.

#### [x] 7.8 Stub event page
**File**: `frontend/src/views/EventStubView.vue`
**Changes**: New file. Minimal confirmation page after event creation. Shows:
- "Event created!" confirmation
- Shareable event URL with copy-to-clipboard button
- Back link to home

This is a temporary stub — US-2 replaces it with the full event page.

#### [x] 7.9 Router update
**File**: `frontend/src/router/index.ts`
**Changes**: Replace scaffold routes with:

```typescript
const routes = [
  { path: '/', name: 'home', component: HomeView },
  { path: '/create', name: 'create-event', component: () => import('../views/EventCreateView.vue') },
  { path: '/events/:token', name: 'event', component: () => import('../views/EventStubView.vue') },
]
```

#### [x] 7.10 Frontend tests
**Files**:
- `frontend/src/composables/__tests__/useEventStorage.spec.ts` — localStorage composable tests
- `frontend/src/views/__tests__/EventCreateView.spec.ts` — form rendering, validation, submit flow

Test cases for localStorage composable:
- `saveCreatedEvent` stores data retrievable by `getStoredEvents`
- `getOrganizerToken` returns token for known event, undefined for unknown
- Multiple events stored independently

Test cases for EventCreateView:
- Renders all form fields
- Required fields have `required` attribute
- Submit button exists
- Form validation prevents submit with empty required fields
- (API call mocking for submit flow)

### Success Criteria:

#### Automated Verification:
- [ ] `cd frontend && npm run test:unit` passes — all new tests green
- [ ] `cd frontend && npm run build` succeeds — no TypeScript errors, no build errors
- [ ] `cd frontend && npx eslint src/` passes — no lint errors
- [ ] No references to removed scaffold components remain

#### Manual Verification:
- [ ] Home page shows "Create Event" button on mobile viewport
- [ ] Create form renders with card-style inputs, gradient background
- [ ] Desktop view centers content in narrow column
- [ ] Submitting a valid form creates the event and redirects to stub page
- [ ] Stub page shows shareable link with copy button
- [ ] localStorage contains event data after creation
- [ ] Validation errors display inline on the form (both client-side and server-side)
- [ ] Self-hosted font loads (no external requests in Network tab)

**Implementation Note**: After completing this phase, run the full verification suite, then use the `browser-interactive-testing` skill for visual verification of the form, desktop/mobile layouts, and the complete creation flow.

---

## Testing Strategy

### Unit Tests:
- `EventServiceTest` — business logic, token generation, expiry validation
- `useEventStorage.spec.ts` — localStorage operations
- `EventCreateView.spec.ts` — form rendering, validation

### Integration Tests:
- `EventPersistenceAdapterTest` — JPA ↔ PostgreSQL round-trip (Testcontainers)
- `EventControllerIntegrationTest` — full HTTP request/response cycle (MockMvc + Testcontainers)

### Architecture Tests:
- Existing ArchUnit tests validate all hexagonal layer boundaries automatically

### Manual Testing Steps:
1. Start backend + frontend in dev mode
2. Navigate to `/` — see landing page with "Create Event" button
3. Navigate to `/create` — fill out form, submit
4. Verify redirect to `/events/:token` stub page
5. Check localStorage for stored event data
6. Submit invalid form — verify error messages
7. Check Network tab — no external requests (fonts, CDNs, etc.)
8. Test on mobile viewport — verify app-native feel

## Performance Considerations

- UUID indexes on `event_token` and `organizer_token` for O(log n) lookups
- `expiry_date` index prepares for US-12 cleanup queries
- Lazy-loaded route components (`EventCreateView`, `EventStubView`) for code splitting
- Self-hosted font: WOFF2 format for minimal file size

## References

- `docs/agents/research/2026-03-04-us1-create-event.md` — US-1 codebase research
- `docs/agents/research/2026-03-04-rfc9457-problem-details.md` — Error handling research
- `docs/agents/research/2026-03-04-datetime-best-practices.md` — Date/time type mapping
- `spec/design-system.md` — Permanent design spec (palette, font, component patterns)
- `spec/userstories.md:21-41` — US-1 specification
- `spec/implementation-phases.md` — Implementation order
