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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:19:41 +01:00

44 KiB

date, git_commit, branch, topic, tags, status
date git_commit branch topic tags status
2026-03-04T21:41:13+00:00 91e566efea master US-1: Create an Event
plan
us-1
event-creation
full-stack
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:

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 & PortsEvent model, CreateEventUseCase, EventRepository
  4. Application ServiceEventService 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.

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

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

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.

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

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.

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

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.

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.

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.

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 OffsetDateTimetimestamptz and LocalDatedate)

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.

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.

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.

// 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:

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