Compare commits
11 Commits
c2bbb78b7b
...
ffea279b54
| Author | SHA1 | Date | |
|---|---|---|---|
| ffea279b54 | |||
| 4cfac860aa | |||
| e3ca613210 | |||
| 14f11875a4 | |||
| a029e951b8 | |||
| 84feeb9997 | |||
| f3d4b5fa17 | |||
| c80074093c | |||
| 830ca55f20 | |||
| eeadaf58c7 | |||
| 387445e089 |
@@ -32,6 +32,11 @@ These are the non-negotiable principles of this project. Every decision — arch
|
||||
- For every feature, critically evaluate what data is necessary. Only store what is absolutely required for functionality.
|
||||
- Never include external dependencies that phone home: no CDNs, no Google Fonts, no tracking-capable libraries.
|
||||
|
||||
### Design
|
||||
|
||||
- The visual design system is defined in `spec/design-system.md`. All frontend implementation must follow it.
|
||||
- Color palette, typography, component patterns, and layout rules are specified there — do not deviate without explicit approval.
|
||||
|
||||
### Quality
|
||||
|
||||
- KISS and grugbrain. Engineer it properly, but don't over-engineer.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.adapter.in.web.api.EventsApi;
|
||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.port.in.CreateEventUseCase;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** REST controller for event operations. */
|
||||
@RestController
|
||||
public class EventController implements EventsApi {
|
||||
|
||||
private final CreateEventUseCase createEventUseCase;
|
||||
|
||||
/** Creates a new controller with the given use case. */
|
||||
public EventController(CreateEventUseCase createEventUseCase) {
|
||||
this.createEventUseCase = createEventUseCase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<CreateEventResponse> createEvent(
|
||||
CreateEventRequest request) {
|
||||
var command = new CreateEventCommand(
|
||||
request.getTitle(),
|
||||
request.getDescription(),
|
||||
request.getDateTime(),
|
||||
request.getLocation(),
|
||||
request.getExpiryDate()
|
||||
);
|
||||
|
||||
Event event = createEventUseCase.createEvent(command);
|
||||
|
||||
var response = new CreateEventResponse();
|
||||
response.setEventToken(event.getEventToken());
|
||||
response.setOrganizerToken(event.getOrganizerToken());
|
||||
response.setTitle(event.getTitle());
|
||||
response.setDateTime(event.getDateTime());
|
||||
response.setExpiryDate(event.getExpiryDate());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.application.service.ExpiryDateInPastException;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||
|
||||
/** Global exception handler producing RFC 9457 Problem Details responses. */
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
|
||||
ProblemDetail problemDetail = ex.getBody();
|
||||
problemDetail.setTitle("Validation Failed");
|
||||
problemDetail.setType(URI.create("urn:problem-type:validation-error"));
|
||||
|
||||
List<Map<String, String>> fieldErrors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.map(fe -> Map.of(
|
||||
"field", fe.getField(),
|
||||
"message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid"
|
||||
))
|
||||
.toList();
|
||||
|
||||
problemDetail.setProperty("fieldErrors", fieldErrors);
|
||||
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
||||
}
|
||||
|
||||
/** Handles expiry date validation failures. */
|
||||
@ExceptionHandler(ExpiryDateInPastException.class)
|
||||
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
|
||||
ExpiryDateInPastException ex) {
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
problemDetail.setTitle("Invalid Expiry Date");
|
||||
problemDetail.setType(URI.create("urn:problem-type:expiry-date-in-past"));
|
||||
return ResponseEntity.badRequest()
|
||||
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Catches all unhandled exceptions. */
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ProblemDetail> handleAll(Exception ex) {
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"An unexpected error occurred.");
|
||||
problemDetail.setTitle("Internal Server Error");
|
||||
return ResponseEntity.internalServerError()
|
||||
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||
.body(problemDetail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package de.fete.adapter.out.persistence;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/** JPA entity mapping to the events table. */
|
||||
@Entity
|
||||
@Table(name = "events")
|
||||
public class EventJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "event_token", nullable = false, unique = true)
|
||||
private UUID eventToken;
|
||||
|
||||
@Column(name = "organizer_token", nullable = false, unique = true)
|
||||
private UUID organizerToken;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(length = 2000)
|
||||
private String description;
|
||||
|
||||
@Column(name = "date_time", nullable = false)
|
||||
private OffsetDateTime dateTime;
|
||||
|
||||
@Column(length = 500)
|
||||
private String location;
|
||||
|
||||
@Column(name = "expiry_date", nullable = false)
|
||||
private LocalDate expiryDate;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
/** Returns the internal database ID. */
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Sets the internal database ID. */
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Returns the public event token. */
|
||||
public UUID getEventToken() {
|
||||
return eventToken;
|
||||
}
|
||||
|
||||
/** Sets the public event token. */
|
||||
public void setEventToken(UUID eventToken) {
|
||||
this.eventToken = eventToken;
|
||||
}
|
||||
|
||||
/** Returns the secret organizer token. */
|
||||
public UUID getOrganizerToken() {
|
||||
return organizerToken;
|
||||
}
|
||||
|
||||
/** Sets the secret organizer token. */
|
||||
public void setOrganizerToken(UUID organizerToken) {
|
||||
this.organizerToken = organizerToken;
|
||||
}
|
||||
|
||||
/** Returns the event title. */
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
/** Sets the event title. */
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
/** Returns the event description. */
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/** Sets the event description. */
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/** Returns the event date and time. */
|
||||
public OffsetDateTime getDateTime() {
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
/** Sets the event date and time. */
|
||||
public void setDateTime(OffsetDateTime dateTime) {
|
||||
this.dateTime = dateTime;
|
||||
}
|
||||
|
||||
/** Returns the event location. */
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
/** Sets the event location. */
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
/** Returns the expiry date. */
|
||||
public LocalDate getExpiryDate() {
|
||||
return expiryDate;
|
||||
}
|
||||
|
||||
/** Sets the expiry date. */
|
||||
public void setExpiryDate(LocalDate expiryDate) {
|
||||
this.expiryDate = expiryDate;
|
||||
}
|
||||
|
||||
/** Returns the creation timestamp. */
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/** Sets the creation timestamp. */
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.fete.adapter.out.persistence;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Spring Data JPA repository for event entities. */
|
||||
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
||||
|
||||
/** Finds an event by its public event token. */
|
||||
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.fete.adapter.out.persistence;
|
||||
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** Persistence adapter implementing the EventRepository outbound port. */
|
||||
@Repository
|
||||
public class EventPersistenceAdapter implements EventRepository {
|
||||
|
||||
private final EventJpaRepository jpaRepository;
|
||||
|
||||
/** Creates a new adapter with the given JPA repository. */
|
||||
public EventPersistenceAdapter(EventJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Event save(Event event) {
|
||||
EventJpaEntity entity = toEntity(event);
|
||||
EventJpaEntity saved = jpaRepository.save(entity);
|
||||
return toDomain(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Event> findByEventToken(UUID eventToken) {
|
||||
return jpaRepository.findByEventToken(eventToken).map(this::toDomain);
|
||||
}
|
||||
|
||||
private EventJpaEntity toEntity(Event event) {
|
||||
var entity = new EventJpaEntity();
|
||||
entity.setId(event.getId());
|
||||
entity.setEventToken(event.getEventToken());
|
||||
entity.setOrganizerToken(event.getOrganizerToken());
|
||||
entity.setTitle(event.getTitle());
|
||||
entity.setDescription(event.getDescription());
|
||||
entity.setDateTime(event.getDateTime());
|
||||
entity.setLocation(event.getLocation());
|
||||
entity.setExpiryDate(event.getExpiryDate());
|
||||
entity.setCreatedAt(event.getCreatedAt());
|
||||
return entity;
|
||||
}
|
||||
|
||||
private Event toDomain(EventJpaEntity entity) {
|
||||
var event = new Event();
|
||||
event.setId(entity.getId());
|
||||
event.setEventToken(entity.getEventToken());
|
||||
event.setOrganizerToken(entity.getOrganizerToken());
|
||||
event.setTitle(entity.getTitle());
|
||||
event.setDescription(entity.getDescription());
|
||||
event.setDateTime(entity.getDateTime());
|
||||
event.setLocation(entity.getLocation());
|
||||
event.setExpiryDate(entity.getExpiryDate());
|
||||
event.setCreatedAt(entity.getCreatedAt());
|
||||
return event;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.port.in.CreateEventUseCase;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Application service implementing event creation. */
|
||||
@Service
|
||||
public class EventService implements CreateEventUseCase {
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
private final Clock clock;
|
||||
|
||||
/** Creates a new EventService with the given repository and clock. */
|
||||
public EventService(EventRepository eventRepository, Clock clock) {
|
||||
this.eventRepository = eventRepository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Event createEvent(CreateEventCommand command) {
|
||||
if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
|
||||
throw new ExpiryDateInPastException(command.expiryDate());
|
||||
}
|
||||
|
||||
var event = new Event();
|
||||
event.setEventToken(UUID.randomUUID());
|
||||
event.setOrganizerToken(UUID.randomUUID());
|
||||
event.setTitle(command.title());
|
||||
event.setDescription(command.description());
|
||||
event.setDateTime(command.dateTime());
|
||||
event.setLocation(command.location());
|
||||
event.setExpiryDate(command.expiryDate());
|
||||
event.setCreatedAt(OffsetDateTime.now(clock));
|
||||
|
||||
return eventRepository.save(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** Thrown when an event's expiry date is not in the future. */
|
||||
public class ExpiryDateInPastException extends RuntimeException {
|
||||
|
||||
private final LocalDate expiryDate;
|
||||
|
||||
/** Creates a new exception for the given invalid expiry date. */
|
||||
public ExpiryDateInPastException(LocalDate expiryDate) {
|
||||
super("Expiry date must be in the future: " + expiryDate);
|
||||
this.expiryDate = expiryDate;
|
||||
}
|
||||
|
||||
/** Returns the invalid expiry date. */
|
||||
public LocalDate getExpiryDate() {
|
||||
return expiryDate;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.fete.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -14,6 +16,11 @@ import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemDefaultZone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configurePathMatch(PathMatchConfigurer configurer) {
|
||||
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.fete.domain.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/** Command carrying the data needed to create an event. */
|
||||
public record CreateEventCommand(
|
||||
String title,
|
||||
String description,
|
||||
OffsetDateTime dateTime,
|
||||
String location,
|
||||
LocalDate expiryDate
|
||||
) {}
|
||||
109
backend/src/main/java/de/fete/domain/model/Event.java
Normal file
109
backend/src/main/java/de/fete/domain/model/Event.java
Normal file
@@ -0,0 +1,109 @@
|
||||
package de.fete.domain.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Domain entity representing an event. */
|
||||
public class Event {
|
||||
|
||||
private Long id;
|
||||
private UUID eventToken;
|
||||
private UUID organizerToken;
|
||||
private String title;
|
||||
private String description;
|
||||
private OffsetDateTime dateTime;
|
||||
private String location;
|
||||
private LocalDate expiryDate;
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
/** Returns the internal database ID. */
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Sets the internal database ID. */
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Returns the public event token (UUID). */
|
||||
public UUID getEventToken() {
|
||||
return eventToken;
|
||||
}
|
||||
|
||||
/** Sets the public event token. */
|
||||
public void setEventToken(UUID eventToken) {
|
||||
this.eventToken = eventToken;
|
||||
}
|
||||
|
||||
/** Returns the secret organizer token (UUID). */
|
||||
public UUID getOrganizerToken() {
|
||||
return organizerToken;
|
||||
}
|
||||
|
||||
/** Sets the secret organizer token. */
|
||||
public void setOrganizerToken(UUID organizerToken) {
|
||||
this.organizerToken = organizerToken;
|
||||
}
|
||||
|
||||
/** Returns the event title. */
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
/** Sets the event title. */
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
/** Returns the event description. */
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/** Sets the event description. */
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/** Returns the event date and time with UTC offset. */
|
||||
public OffsetDateTime getDateTime() {
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
/** Sets the event date and time. */
|
||||
public void setDateTime(OffsetDateTime dateTime) {
|
||||
this.dateTime = dateTime;
|
||||
}
|
||||
|
||||
/** Returns the event location. */
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
/** Sets the event location. */
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
/** Returns the expiry date after which event data is deleted. */
|
||||
public LocalDate getExpiryDate() {
|
||||
return expiryDate;
|
||||
}
|
||||
|
||||
/** Sets the expiry date. */
|
||||
public void setExpiryDate(LocalDate expiryDate) {
|
||||
this.expiryDate = expiryDate;
|
||||
}
|
||||
|
||||
/** Returns the creation timestamp. */
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/** Sets the creation timestamp. */
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.fete.domain.port.in;
|
||||
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
|
||||
/** Inbound port for creating a new event. */
|
||||
public interface CreateEventUseCase {
|
||||
|
||||
/** Creates an event from the given command and returns the persisted event. */
|
||||
Event createEvent(CreateEventCommand command);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.fete.domain.port.out;
|
||||
|
||||
import de.fete.domain.model.Event;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Outbound port for persisting and retrieving events. */
|
||||
public interface EventRepository {
|
||||
|
||||
/** Persists the given event and returns it with generated fields populated. */
|
||||
Event save(Event event);
|
||||
|
||||
/** Finds an event by its public event token. */
|
||||
Optional<Event> findByEventToken(UUID eventToken);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="001-create-events-table" author="fete">
|
||||
<createTable tableName="events">
|
||||
<column name="id" type="bigserial" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="event_token" type="uuid">
|
||||
<constraints nullable="false" unique="true"/>
|
||||
</column>
|
||||
<column name="organizer_token" type="uuid">
|
||||
<constraints nullable="false" unique="true"/>
|
||||
</column>
|
||||
<column name="title" type="varchar(200)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="description" type="varchar(2000)"/>
|
||||
<column name="date_time" type="timestamptz">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="location" type="varchar(500)"/>
|
||||
<column name="expiry_date" type="date">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<createIndex tableName="events" indexName="idx_events_event_token">
|
||||
<column name="event_token"/>
|
||||
</createIndex>
|
||||
|
||||
<createIndex tableName="events" indexName="idx_events_expiry_date">
|
||||
<column name="expiry_date"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -6,5 +6,6 @@
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<include file="db/changelog/000-baseline.xml"/>
|
||||
<include file="db/changelog/001-create-events-table.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -11,27 +11,120 @@ servers:
|
||||
- url: /api
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
operationId: getHealth
|
||||
summary: Health check
|
||||
/events:
|
||||
post:
|
||||
operationId: createEvent
|
||||
summary: Create a new event
|
||||
tags:
|
||||
- health
|
||||
- events
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateEventRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Service is healthy
|
||||
"201":
|
||||
description: Event created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HealthResponse"
|
||||
$ref: "#/components/schemas/CreateEventResponse"
|
||||
"400":
|
||||
description: Validation failed
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
HealthResponse:
|
||||
CreateEventRequest:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- title
|
||||
- dateTime
|
||||
- expiryDate
|
||||
properties:
|
||||
status:
|
||||
title:
|
||||
type: string
|
||||
example: UP
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
description:
|
||||
type: string
|
||||
maxLength: 2000
|
||||
dateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Event date and time with UTC offset (ISO 8601)
|
||||
example: "2026-03-15T20:00:00+01:00"
|
||||
location:
|
||||
type: string
|
||||
maxLength: 500
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
description: Date after which event data is deleted. Must be in the future.
|
||||
example: "2026-06-15"
|
||||
|
||||
CreateEventResponse:
|
||||
type: object
|
||||
required:
|
||||
- eventToken
|
||||
- organizerToken
|
||||
- title
|
||||
- dateTime
|
||||
- expiryDate
|
||||
properties:
|
||||
eventToken:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Public token for the event URL
|
||||
organizerToken:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Secret token for organizer access
|
||||
title:
|
||||
type: string
|
||||
dateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
format: uri
|
||||
default: "about:blank"
|
||||
title:
|
||||
type: string
|
||||
status:
|
||||
type: integer
|
||||
detail:
|
||||
type: string
|
||||
instance:
|
||||
type: string
|
||||
format: uri
|
||||
additionalProperties: true
|
||||
|
||||
ValidationProblemDetail:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ProblemDetail"
|
||||
- type: object
|
||||
properties:
|
||||
fieldErrors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- field
|
||||
- message
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package de.fete.adapter.in.web;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import de.fete.TestcontainersConfig;
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfig.class)
|
||||
class EventControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void createEventWithValidBody() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "Birthday Party",
|
||||
"description": "Come celebrate!",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"location": "Berlin",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
||||
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.expiryDate").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventWithOptionalFieldsNull() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "Minimal Event",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.eventToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.organizerToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.title").value("Minimal Event"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventMissingTitleReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.title").value("Validation Failed"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventMissingDateTimeReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "No Date",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventMissingExpiryDateReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "No Expiry",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateInPastReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "Past Expiry",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "2025-01-01"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateTodayReturns400() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "Today Expiry",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now());
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorResponseContentTypeIsProblemJson() throws Exception {
|
||||
String body =
|
||||
"""
|
||||
{
|
||||
"title": "",
|
||||
"dateTime": "2026-06-15T20:00:00+02:00",
|
||||
"expiryDate": "%s"
|
||||
}
|
||||
""".formatted(LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package de.fete.adapter.out.persistence;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import de.fete.TestcontainersConfig;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
@SpringBootTest
|
||||
@Import(TestcontainersConfig.class)
|
||||
class EventPersistenceAdapterTest {
|
||||
|
||||
@Autowired
|
||||
private EventRepository eventRepository;
|
||||
|
||||
@Test
|
||||
void saveReturnsEventWithGeneratedId() {
|
||||
Event event = buildEvent();
|
||||
|
||||
Event saved = eventRepository.save(event);
|
||||
|
||||
assertThat(saved.getId()).isNotNull();
|
||||
assertThat(saved.getTitle()).isEqualTo("Test Event");
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedEventIsFoundByEventToken() {
|
||||
Event event = buildEvent();
|
||||
Event saved = eventRepository.save(event);
|
||||
|
||||
Optional<Event> found = eventRepository.findByEventToken(saved.getEventToken());
|
||||
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getTitle()).isEqualTo("Test Event");
|
||||
assertThat(found.get().getId()).isEqualTo(saved.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByUnknownEventTokenReturnsEmpty() {
|
||||
Optional<Event> found = eventRepository.findByEventToken(UUID.randomUUID());
|
||||
|
||||
assertThat(found).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void allFieldsRoundTripCorrectly() {
|
||||
OffsetDateTime dateTime =
|
||||
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2));
|
||||
LocalDate expiryDate = LocalDate.of(2026, 7, 15);
|
||||
OffsetDateTime createdAt =
|
||||
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
||||
|
||||
var event = new Event();
|
||||
event.setEventToken(UUID.randomUUID());
|
||||
event.setOrganizerToken(UUID.randomUUID());
|
||||
event.setTitle("Full Event");
|
||||
event.setDescription("A detailed description");
|
||||
event.setDateTime(dateTime);
|
||||
event.setLocation("Berlin, Germany");
|
||||
event.setExpiryDate(expiryDate);
|
||||
event.setCreatedAt(createdAt);
|
||||
|
||||
Event saved = eventRepository.save(event);
|
||||
Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow();
|
||||
|
||||
assertThat(found.getEventToken()).isEqualTo(event.getEventToken());
|
||||
assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken());
|
||||
assertThat(found.getTitle()).isEqualTo("Full Event");
|
||||
assertThat(found.getDescription()).isEqualTo("A detailed description");
|
||||
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
|
||||
assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
|
||||
assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
|
||||
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
|
||||
}
|
||||
|
||||
private Event buildEvent() {
|
||||
var event = new Event();
|
||||
event.setEventToken(UUID.randomUUID());
|
||||
event.setOrganizerToken(UUID.randomUUID());
|
||||
event.setTitle("Test Event");
|
||||
event.setDescription("Test description");
|
||||
event.setDateTime(OffsetDateTime.now().plusDays(7));
|
||||
event.setLocation("Somewhere");
|
||||
event.setExpiryDate(LocalDate.now().plusDays(30));
|
||||
event.setCreatedAt(OffsetDateTime.now());
|
||||
return event;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EventServiceTest {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
|
||||
private static final Instant FIXED_INSTANT =
|
||||
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
|
||||
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
|
||||
|
||||
@Mock
|
||||
private EventRepository eventRepository;
|
||||
|
||||
private EventService eventService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
eventService = new EventService(eventRepository, FIXED_CLOCK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventWithValidCommand() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Birthday Party",
|
||||
"Come celebrate!",
|
||||
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||
"Berlin",
|
||||
LocalDate.of(2026, 7, 15)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Birthday Party");
|
||||
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
||||
assertThat(result.getLocation()).isEqualTo("Berlin");
|
||||
assertThat(result.getEventToken()).isNotNull();
|
||||
assertThat(result.getOrganizerToken()).isNotNull();
|
||||
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK));
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventTokenAndOrganizerTokenAreDifferent() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getEventToken()).isNotEqualTo(result.getOrganizerToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repositorySaveCalledExactlyOnce() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
||||
);
|
||||
|
||||
eventService.createEvent(command);
|
||||
|
||||
ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
|
||||
verify(eventRepository, times(1)).save(captor.capture());
|
||||
assertThat(captor.getValue().getTitle()).isEqualTo("Test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateTodayThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
LocalDate.now(FIXED_CLOCK)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateInPastException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateInPastThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
LocalDate.now(FIXED_CLOCK).minusDays(5)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateInPastException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateTomorrowSucceeds() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(1)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6));
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,8 @@ class WebConfigTest {
|
||||
|
||||
@Test
|
||||
void apiPrefixNotAccessibleWithoutIt() throws Exception {
|
||||
// /health without /api prefix should not resolve to the API endpoint
|
||||
mockMvc.perform(get("/health"))
|
||||
// /events without /api prefix should not resolve to the API endpoint
|
||||
mockMvc.perform(get("/events"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
1152
docs/agents/plan/2026-03-04-us1-create-event.md
Normal file
1152
docs/agents/plan/2026-03-04-us1-create-event.md
Normal file
File diff suppressed because it is too large
Load Diff
199
docs/agents/plan/2026-03-05-us1-post-review-fixes.md
Normal file
199
docs/agents/plan/2026-03-05-us1-post-review-fixes.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# US-1 Post-Review Fixes — Implementation Plan
|
||||
|
||||
Date: 2026-03-05
|
||||
Origin: Deep review of all unstaged US-1 changes before commit
|
||||
|
||||
## Context
|
||||
|
||||
US-1 "Create Event" is fully implemented (backend + frontend, 7 phases) with 4 review fixes already applied (reactive error clearing, network error handling, page title, favicon). A comprehensive review of ALL unstaged files revealed additional issues that must be fixed before committing.
|
||||
|
||||
## Task 1: Backend — Clock injection in EventService [x]
|
||||
|
||||
**Problem:** `EventService` uses `LocalDate.now()` and `OffsetDateTime.now()` directly, making deterministic time-based testing impossible.
|
||||
|
||||
**Files:**
|
||||
- `backend/src/main/java/de/fete/application/service/EventService.java`
|
||||
- `backend/src/test/java/de/fete/application/service/EventServiceTest.java`
|
||||
|
||||
**Fix:**
|
||||
1. Inject a `java.time.Clock` bean into `EventService` via constructor
|
||||
2. Replace `LocalDate.now()` with `LocalDate.now(clock)` and `OffsetDateTime.now()` with `OffsetDateTime.now(clock)`
|
||||
3. Add a `Clock` bean to the Spring config (or rely on a `@Bean Clock clock() { return Clock.systemDefaultZone(); }` in a config class)
|
||||
4. Update `EventServiceTest` to use `Clock.fixed(...)` for deterministic tests
|
||||
|
||||
**Verification:** `cd backend && ./mvnw test`
|
||||
|
||||
## Task 2: Frontend A11y — Error spans should only render when error present [x]
|
||||
|
||||
**Problem:** Every form field has `<span class="field-error" role="alert">{{ errors.title }}</span>` that is always in the DOM, even when empty. Screen readers may announce empty `role="alert"` elements.
|
||||
|
||||
**File:** `frontend/src/views/EventCreateView.vue`
|
||||
|
||||
**Fix:** Use `v-if` to conditionally render error spans:
|
||||
```html
|
||||
<span v-if="errors.title" class="field-error" role="alert">{{ errors.title }}</span>
|
||||
```
|
||||
|
||||
Apply to all 5 field error spans (title, description, dateTime, location, expiryDate).
|
||||
|
||||
**Note:** This removes the `min-height: 1.2em` layout reservation. Accept the layout shift as a trade-off for accessibility, OR add a wrapper div with `min-height` that doesn't carry `role="alert"`.
|
||||
|
||||
**Verification:** `cd frontend && npm run test:unit` — existing tests use `.querySelector('[role="alert"]')` so they may need adjustment since empty alerts will no longer be in the DOM.
|
||||
|
||||
## Task 3: Frontend A11y — aria-invalid and aria-describedby on fields [x]
|
||||
|
||||
**Problem:** When a field fails validation, there is no `aria-invalid="true"` or `aria-describedby` linking the input to its error message. Assistive technologies cannot associate errors with fields.
|
||||
|
||||
**File:** `frontend/src/views/EventCreateView.vue`
|
||||
|
||||
**Fix:**
|
||||
1. Add unique `id` to each error span (e.g., `id="title-error"`)
|
||||
2. Add `:aria-describedby="errors.title ? 'title-error' : undefined"` to each input
|
||||
3. Add `:aria-invalid="!!errors.title"` to each input
|
||||
|
||||
Example for title:
|
||||
```html
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
class="form-field"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="What's the event?"
|
||||
:aria-invalid="!!errors.title"
|
||||
:aria-describedby="errors.title ? 'title-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.title" id="title-error" class="field-error" role="alert">{{ errors.title }}</span>
|
||||
```
|
||||
|
||||
Apply the same pattern to all 5 fields (title, description, dateTime, location, expiryDate).
|
||||
|
||||
**Verification:** `cd frontend && npm run test:unit`
|
||||
|
||||
## Task 4: Frontend A11y — Error text contrast [x]
|
||||
|
||||
**Problem:** White (`#fff`) error text on the pink gradient start (`#F06292`) has a contrast ratio of only 3.06:1, which fails WCAG AA for small text (0.8rem). The project statute requires WCAG AA compliance.
|
||||
|
||||
**File:** `frontend/src/assets/main.css`
|
||||
|
||||
**Fix options (pick one):**
|
||||
- **Option A:** Use a light yellow/cream color like `#FFF9C4` or `#FFECB3` that has higher contrast on the gradient
|
||||
- **Option B:** Add a subtle dark text-shadow to the error text: `text-shadow: 0 1px 2px rgba(0,0,0,0.3)`
|
||||
- **Option C:** Make error text slightly larger/bolder to qualify for WCAG AA-large (18px+ or 14px+ bold)
|
||||
|
||||
**Recommended:** Option C — bump `.field-error` to `font-size: 0.85rem; font-weight: 600;` which at 600 weight qualifies for AA-large text at 14px+ (0.85rem ≈ 13.6px — close but may not quite qualify). Alternatively combine with option B for safety.
|
||||
|
||||
**Note:** Verify the final choice against the design system spec in `spec/design-system.md`. The spec notes that gradient start only passes AA-large. The error text must work across the full gradient.
|
||||
|
||||
**Verification:** Manual contrast check with a tool like WebAIM contrast checker.
|
||||
|
||||
## Task 5: Test — Happy-path submission in EventCreateView [x]
|
||||
|
||||
**Problem:** No test verifies successful form submission (the most important behavior).
|
||||
|
||||
**File:** `frontend/src/views/__tests__/EventCreateView.spec.ts`
|
||||
|
||||
**Fix:** Add a test that:
|
||||
1. Mocks `api.POST` to return `{ data: { eventToken: 'abc', organizerToken: 'xyz', title: 'Test', dateTime: '...', expiryDate: '...' } }`
|
||||
2. Fills all required fields
|
||||
3. Submits the form
|
||||
4. Asserts `api.POST` was called with the correct body
|
||||
5. Asserts navigation to `/events/abc` occurred
|
||||
6. Asserts `saveCreatedEvent` was called (need to mock `useEventStorage`)
|
||||
|
||||
**Note:** `useEventStorage` must be mocked. Use `vi.mock('@/composables/useEventStorage')`.
|
||||
|
||||
**Verification:** `cd frontend && npm run test:unit`
|
||||
|
||||
## Task 6: Test — EventStubView component tests [x]
|
||||
|
||||
**Problem:** No test file exists for `EventStubView.vue`.
|
||||
|
||||
**New file:** `frontend/src/views/__tests__/EventStubView.spec.ts`
|
||||
|
||||
**Fix:** Create tests covering:
|
||||
1. Renders the event URL based on route param `:token`
|
||||
2. Shows the correct share URL (`window.location.origin + /events/:token`)
|
||||
3. Copy button exists
|
||||
4. Back link navigates to home
|
||||
|
||||
**Note:** Read `frontend/src/views/EventStubView.vue` first to understand the component structure.
|
||||
|
||||
**Verification:** `cd frontend && npm run test:unit`
|
||||
|
||||
## Task 7: Test — Server-side field errors in EventCreateView [x]
|
||||
|
||||
**Problem:** The `fieldErrors` handling branch (lines 184-196 of EventCreateView.vue) is untested.
|
||||
|
||||
**File:** `frontend/src/views/__tests__/EventCreateView.spec.ts`
|
||||
|
||||
**Fix:** Add a test that:
|
||||
1. Mocks `api.POST` to return `{ error: { fieldErrors: [{ field: 'title', message: 'Title already taken' }] } }`
|
||||
2. Fills all required fields and submits
|
||||
3. Asserts the title field error shows "Title already taken"
|
||||
4. Asserts other field errors are empty
|
||||
|
||||
**Verification:** `cd frontend && npm run test:unit`
|
||||
|
||||
## Task 8: Fix border-radius on EventStubView copy button [x]
|
||||
|
||||
**Problem:** `border-radius: 10px` is hardcoded instead of using the design token `var(--radius-button)` (14px).
|
||||
|
||||
**File:** `frontend/src/views/EventStubView.vue`
|
||||
|
||||
**Fix:** Replace `border-radius: 10px` with `border-radius: var(--radius-button)` in the `.stub__copy` CSS class.
|
||||
|
||||
**Verification:** Visual check.
|
||||
|
||||
## Task 9: Add 404 catch-all route user story [x]
|
||||
|
||||
**Problem:** Navigating to an unknown path shows a blank page.
|
||||
|
||||
**File:** `spec/userstories.md`
|
||||
|
||||
**Fix:** Add a new user story for a 404/catch-all route. Something like:
|
||||
|
||||
```
|
||||
### US-X: 404 Page
|
||||
|
||||
As a user who navigates to a non-existent URL, I want to see a helpful error page so I can find my way back.
|
||||
|
||||
Acceptance Criteria:
|
||||
- [ ] Unknown routes show a "Page not found" message
|
||||
- [ ] The page includes a link back to the home page
|
||||
- [ ] The page follows the design system
|
||||
```
|
||||
|
||||
Read the existing user stories first to match the format.
|
||||
|
||||
**Verification:** N/A (spec only).
|
||||
|
||||
## Task 10: EventStubView silent clipboard failure [x]
|
||||
|
||||
**Problem:** In `EventStubView.vue`, the `catch` block on `navigator.clipboard.writeText()` is empty. If clipboard is unavailable (HTTP, older browser), the user gets no feedback.
|
||||
|
||||
**File:** `frontend/src/views/EventStubView.vue`
|
||||
|
||||
**Fix:** In the catch block, show a fallback message (e.g., set `copied` text to "Copy failed" or select the URL text for manual copying).
|
||||
|
||||
**Verification:** `cd frontend && npm run test:unit`
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Task 1 (Clock injection — backend, independent)
|
||||
2. Tasks 2 + 3 (A11y fixes — can be done together since they touch the same file)
|
||||
3. Task 4 (Contrast fix — CSS only)
|
||||
4. Tasks 5 + 7 (EventCreateView tests — same test file)
|
||||
5. Task 6 (EventStubView tests — new file)
|
||||
6. Tasks 8 + 10 (EventStubView fixes — same file)
|
||||
7. Task 9 (User story — spec only)
|
||||
8. Run all tests: `cd backend && ./mvnw test` and `cd frontend && npm run test:unit`
|
||||
|
||||
## Constraints
|
||||
|
||||
- TDD: write/update tests first, then fix (where applicable)
|
||||
- Follow existing code style and patterns
|
||||
- Do not refactor unrelated code
|
||||
- Do not add dependencies
|
||||
- Update design system spec if contrast solution changes the spec
|
||||
109
docs/agents/plan/2026-03-05-us1-review-fixes.md
Normal file
109
docs/agents/plan/2026-03-05-us1-review-fixes.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# US-1 Review Fixes — Agent Instructions
|
||||
|
||||
Date: 2026-03-05
|
||||
Origin: Code review and exploratory browser testing of US-1 "Create Event"
|
||||
|
||||
## Context
|
||||
|
||||
US-1 has been implemented across all 7 phases (OpenAPI spec, DB migration, domain model, application service, persistence adapter, web adapter, frontend). All 42 tests pass. A code review with exploratory browser testing found 2 bugs and 2 minor issues that need to be fixed before the story can be committed.
|
||||
|
||||
### Resources
|
||||
|
||||
- **Test report:** `.agent-tests/2026-03-05-us1-review-test/report.md` — full browser test protocol with screenshots
|
||||
- **Screenshots:** `.agent-tests/2026-03-05-us1-review-test/screenshots/` — visual evidence (01–08)
|
||||
- **US-1 spec:** `spec/userstories.md` — acceptance criteria
|
||||
- **Implementation plan:** `docs/agents/plan/2026-03-04-us1-create-event.md`
|
||||
- **Design system:** `spec/design-system.md`
|
||||
- **Primary file to modify:** `frontend/src/views/EventCreateView.vue`
|
||||
- **Secondary file to modify:** `frontend/index.html`
|
||||
|
||||
## Fix Instructions
|
||||
|
||||
### Fix 1: Validation errors must clear reactively (Bug — Medium)
|
||||
|
||||
**Problem:** After submitting the empty form, validation errors appear correctly. But when the user then fills in the fields, the error messages persist until the next submit. See screenshot `05-form-filled.png` — all fields filled, errors still visible.
|
||||
|
||||
**Root cause:** `validate()` (line 125) calls `clearErrors()` only on submit. There is no reactive clearing on input.
|
||||
|
||||
**Fix:** Add a `watch` on the `form` reactive object that clears the corresponding field error when the value changes. Do NOT re-validate on every keystroke — just clear the error for the field that was touched.
|
||||
|
||||
```typescript
|
||||
// Clear individual field errors when the user types
|
||||
watch(() => form.title, () => { errors.title = '' })
|
||||
watch(() => form.dateTime, () => { errors.dateTime = '' })
|
||||
watch(() => form.expiryDate, () => { errors.expiryDate = '' })
|
||||
```
|
||||
|
||||
Also clear `serverError` when any field changes, so stale server errors don't linger.
|
||||
|
||||
**Test:** Add a test to `frontend/src/views/__tests__/EventCreateView.spec.ts` that:
|
||||
1. Submits the empty form (triggers validation errors)
|
||||
2. Types into the title field
|
||||
3. Asserts that the title error is cleared but other errors remain
|
||||
|
||||
### Fix 2: Network errors must show a user-visible message (Bug — High)
|
||||
|
||||
**Problem:** When the backend is unreachable, the form submits silently — no error message, no feedback. The `serverError` element (line 77) exists but is never populated because `openapi-fetch` throws an unhandled exception on network errors instead of returning an `{ error }` object.
|
||||
|
||||
**Root cause:** `handleSubmit()` (line 150) has no `try-catch` around the `api.POST()` call (line 164). When `fetch` fails (network error), `openapi-fetch` throws, the promise rejects, and the function exits without setting `serverError` or resetting `submitting`.
|
||||
|
||||
**Fix:** Wrap the API call and response handling in a `try-catch`:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const { data, error } = await api.POST('/events', { body: { ... } })
|
||||
|
||||
submitting.value = false
|
||||
|
||||
if (error) {
|
||||
// ... existing error handling ...
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// ... existing success handling ...
|
||||
}
|
||||
} catch {
|
||||
submitting.value = false
|
||||
serverError.value = 'Could not reach the server. Please try again.'
|
||||
}
|
||||
```
|
||||
|
||||
**Test:** Add a test to `EventCreateView.spec.ts` that mocks the API to throw (simulating network failure) and asserts that `serverError` text appears in the DOM.
|
||||
|
||||
### Fix 3: Page title (Minor — Low)
|
||||
|
||||
**Problem:** `frontend/index.html` line 7 still has `<title>Vite App</title>`.
|
||||
|
||||
**Fix:** Change to `<title>fete</title>`. Also set `lang="en"` on the `<html>` tag (line 2 currently has `lang=""`).
|
||||
|
||||
**File:** `frontend/index.html`
|
||||
|
||||
### Fix 4: Favicon (Minor — Low)
|
||||
|
||||
**Problem:** The favicon is the Vite default. The project should either have its own favicon or remove the link entirely.
|
||||
|
||||
**Fix:** For now, remove the `<link rel="icon" href="/favicon.ico">` line and delete `frontend/public/favicon.ico` if it exists. A proper favicon can be added later as part of branding work.
|
||||
|
||||
**File:** `frontend/index.html`, `frontend/public/favicon.ico`
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Fix 3 + Fix 4 (trivial, `index.html` + favicon cleanup)
|
||||
2. Fix 1 (reactive error clearing + test)
|
||||
3. Fix 2 (try-catch + test)
|
||||
4. Run all frontend tests: `cd frontend && npm run test:unit`
|
||||
5. Verify visually with `browser-interactive-testing` skill:
|
||||
- Start dev server, open `/create`
|
||||
- Submit empty → errors appear
|
||||
- Fill title → title error clears, others remain
|
||||
- Fill all fields → all errors gone
|
||||
- Submit with no backend → "Could not reach the server" message appears
|
||||
|
||||
## Constraints
|
||||
|
||||
- Follow existing code style and patterns in `EventCreateView.vue`
|
||||
- Do not refactor unrelated code
|
||||
- Do not add dependencies
|
||||
- Tests must follow existing test patterns in `EventCreateView.spec.ts`
|
||||
- TDD: write/update tests first, then fix
|
||||
107
docs/agents/research/2026-03-04-datetime-best-practices.md
Normal file
107
docs/agents/research/2026-03-04-datetime-best-practices.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
date: 2026-03-04T21:15:50+00:00
|
||||
git_commit: b8421274b47c6d1778b83c6b0acb70fd82891e71
|
||||
branch: master
|
||||
topic: "Date/Time Handling Best Practices for the fete Stack"
|
||||
tags: [research, datetime, java, postgresql, openapi, typescript]
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Research: Date/Time Handling Best Practices
|
||||
|
||||
## Research Question
|
||||
|
||||
What are the best practices for handling dates and times across the full fete stack (Java 25 / Spring Boot 3.5.x / PostgreSQL / OpenAPI 3.1 / Vue 3 / TypeScript)?
|
||||
|
||||
## Summary
|
||||
|
||||
The project has two distinct date/time concepts: **event date/time** (when something happens) and **expiry date** (after which data is deleted). These map to different types at every layer. The recommendations align Java types, PostgreSQL column types, OpenAPI formats, and TypeScript representations into a consistent stack-wide approach.
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### Type Mapping Across the Stack
|
||||
|
||||
| Concept | Java | PostgreSQL | OpenAPI | TypeScript | Example |
|
||||
|---------|------|------------|---------|------------|---------|
|
||||
| Event date/time | `OffsetDateTime` | `timestamptz` | `string`, `format: date-time` | `string` | `2026-03-15T20:00:00+01:00` |
|
||||
| Expiry date | `LocalDate` | `date` | `string`, `format: date` | `string` | `2026-06-15` |
|
||||
| Audit timestamps (createdAt, etc.) | `OffsetDateTime` | `timestamptz` | `string`, `format: date-time` | `string` | `2026-03-04T14:22:00Z` |
|
||||
|
||||
### Event Date/Time: `OffsetDateTime` + `timestamptz`
|
||||
|
||||
**Why `OffsetDateTime`, not `LocalDateTime`:**
|
||||
|
||||
- PostgreSQL best practice explicitly recommends `timestamptz` over `timestamp` — the PostgreSQL wiki says ["don't use `timestamp`"](https://wiki.postgresql.org/wiki/Don't_Do_This). `timestamptz` maps naturally to `OffsetDateTime`.
|
||||
- Hibernate 6 (Spring Boot 3.5.x) has native `OffsetDateTime` ↔ `timestamptz` support. `LocalDateTime` requires extra care to avoid silent timezone bugs at the JDBC driver level.
|
||||
- An ISO 8601 string with offset (`2026-03-15T20:00:00+01:00`) is unambiguous in the API. A bare `LocalDateTime` string forces the client to guess the timezone.
|
||||
- The OpenAPI `date-time` format and `openapi-generator` default to `OffsetDateTime` in Java — no custom type mappings needed.
|
||||
|
||||
**Why not `ZonedDateTime`:** Carries IANA zone IDs (e.g. `Europe/Berlin`) which add complexity without value for this use case. Worse JDBC support than `OffsetDateTime`.
|
||||
|
||||
**How PostgreSQL stores it:** `timestamptz` does **not** store the timezone. It converts input to UTC and stores UTC. On retrieval, it converts to the session's timezone setting. The offset is preserved in the Java `OffsetDateTime` via the JDBC driver.
|
||||
|
||||
**Practical flow:** The frontend sends the offset based on the organizer's browser locale. The server stores UTC. Display-side conversion happens in the frontend.
|
||||
|
||||
### Expiry Date: `LocalDate` + `date`
|
||||
|
||||
The expiry date is a calendar-date concept ("after which day should data be deleted"), not a point-in-time. A cleanup job runs periodically and deletes events where `expiryDate < today`. Sub-day precision adds no value and complicates the UX.
|
||||
|
||||
### Jackson Serialization (Spring Boot 3.5.x)
|
||||
|
||||
Spring Boot 3.x auto-configures `jackson-datatype-jsr310` (JavaTimeModule) and disables `WRITE_DATES_AS_TIMESTAMPS` by default:
|
||||
|
||||
- `OffsetDateTime` serializes to `"2026-03-15T20:00:00+01:00"` (ISO 8601 string)
|
||||
- `LocalDate` serializes to `"2026-06-15"`
|
||||
|
||||
No additional configuration needed. For explicitness, can add to `application.properties`:
|
||||
```properties
|
||||
spring.jackson.serialization.write-dates-as-timestamps=false
|
||||
```
|
||||
|
||||
### Hibernate 6 Configuration
|
||||
|
||||
With Hibernate 6, `OffsetDateTime` maps to `timestamptz` using the `NATIVE` timezone storage strategy by default on PostgreSQL. Can be made explicit:
|
||||
|
||||
```properties
|
||||
spring.jpa.properties.hibernate.timezone.default_storage=NATIVE
|
||||
```
|
||||
|
||||
This tells Hibernate to use the database's native `TIMESTAMP WITH TIME ZONE` type directly.
|
||||
|
||||
### OpenAPI Schema Definitions
|
||||
|
||||
```yaml
|
||||
# Event date/time
|
||||
eventDateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2026-03-15T20:00:00+01:00"
|
||||
|
||||
# Expiry date
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
example: "2026-06-15"
|
||||
```
|
||||
|
||||
**Code-generation mapping (defaults, no customization needed):**
|
||||
|
||||
| OpenAPI format | Java type (openapi-generator) | TypeScript type (openapi-typescript) |
|
||||
|---------------|-------------------------------|--------------------------------------|
|
||||
| `date-time` | `java.time.OffsetDateTime` | `string` |
|
||||
| `date` | `java.time.LocalDate` | `string` |
|
||||
|
||||
### Frontend (TypeScript)
|
||||
|
||||
`openapi-typescript` generates `string` for both `format: date-time` and `format: date`. This is correct — JSON has no native date type, so dates travel as strings. Parsing to `Date` objects happens explicitly at the application boundary when needed (e.g. for display formatting).
|
||||
|
||||
## Sources
|
||||
|
||||
- [PostgreSQL Wiki: Don't Do This](https://wiki.postgresql.org/wiki/Don't_Do_This) — recommends `timestamptz` over `timestamp`
|
||||
- [PostgreSQL Docs: Date/Time Types](https://www.postgresql.org/docs/current/datatype-datetime.html)
|
||||
- [Thorben Janssen: Hibernate 6 OffsetDateTime and ZonedDateTime](https://thorben-janssen.com/hibernate-6-offsetdatetime-and-zoneddatetime/)
|
||||
- [Baeldung: OffsetDateTime Serialization With Jackson](https://www.baeldung.com/java-jackson-offsetdatetime)
|
||||
- [Baeldung: Map Date Types With OpenAPI Generator](https://www.baeldung.com/openapi-map-date-types)
|
||||
- [Baeldung: ZonedDateTime vs OffsetDateTime](https://www.baeldung.com/java-zoneddatetime-offsetdatetime)
|
||||
- [Reflectoring: Handling Timezones in Spring Boot](https://reflectoring.io/spring-timezones/)
|
||||
- [openapi-typescript documentation](https://openapi-ts.dev/)
|
||||
202
docs/agents/research/2026-03-04-rfc9457-problem-details.md
Normal file
202
docs/agents/research/2026-03-04-rfc9457-problem-details.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
date: 2026-03-04T21:15:50+00:00
|
||||
git_commit: b8421274b47c6d1778b83c6b0acb70fd82891e71
|
||||
branch: master
|
||||
topic: "RFC 9457 Problem Details for HTTP API Error Responses"
|
||||
tags: [research, error-handling, rfc9457, spring-boot, openapi]
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Research: RFC 9457 Problem Details
|
||||
|
||||
## Research Question
|
||||
|
||||
How should the fete API structure error responses? What does RFC 9457 (Problem Details) specify, and how does it integrate with Spring Boot 3.5.x, OpenAPI 3.1, and openapi-fetch?
|
||||
|
||||
## Summary
|
||||
|
||||
RFC 9457 (successor to RFC 7807) defines a standard JSON format (`application/problem+json`) for machine-readable HTTP API errors. Spring Boot 3.x has first-class support via `ProblemDetail`, `ErrorResponseException`, and `ResponseEntityExceptionHandler`. The recommended approach is a single `@RestControllerAdvice` that handles all exceptions consistently — no `spring.mvc.problemdetails.enabled` property, no fallback to legacy error format.
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### RFC 9457 Format
|
||||
|
||||
Standard fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | URI | Identifies the problem type. Defaults to `about:blank`. |
|
||||
| `title` | string | Short, human-readable summary. Should not change between occurrences. |
|
||||
| `status` | int | HTTP status code. |
|
||||
| `detail` | string | Human-readable explanation specific to this occurrence. |
|
||||
| `instance` | URI | Identifies the specific occurrence (e.g. correlation ID). |
|
||||
|
||||
Extension members (additional JSON properties) are explicitly permitted. This is the mechanism for validation errors, error codes, etc.
|
||||
|
||||
**Key rule:** With `type: "about:blank"`, the `title` must match the HTTP status phrase exactly. Use a custom `type` URI when providing a custom `title`.
|
||||
|
||||
### Spring Boot 3.x Built-in Support
|
||||
|
||||
- **`ProblemDetail`** — container class for the five standard fields + a `properties` Map for extensions.
|
||||
- **`ErrorResponseException`** — base class for custom exceptions that carry their own `ProblemDetail`.
|
||||
- **`ResponseEntityExceptionHandler`** — `@ControllerAdvice` base class that handles all Spring MVC exceptions and renders them as `application/problem+json`.
|
||||
- **`ProblemDetailJacksonMixin`** — automatically unwraps the `properties` Map as top-level JSON fields during serialization.
|
||||
|
||||
### Recommended Configuration
|
||||
|
||||
Use a single `@RestControllerAdvice` extending `ResponseEntityExceptionHandler`. Do **not** use the `spring.mvc.problemdetails.enabled` property.
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
// All Spring MVC exceptions are handled automatically.
|
||||
// Add @ExceptionHandler methods for domain exceptions here.
|
||||
// Add a catch-all for Exception.class to prevent legacy error format.
|
||||
}
|
||||
```
|
||||
|
||||
Reasons to avoid the property-based approach:
|
||||
1. No place to add custom `@ExceptionHandler` methods.
|
||||
2. Having both the property AND a custom `ResponseEntityExceptionHandler` bean causes a conflict.
|
||||
3. The property ignores `server.error.include-*` properties.
|
||||
|
||||
### Validation Errors (Field-Level)
|
||||
|
||||
Spring deliberately does **not** include field-level validation errors in `ProblemDetail` by default (security rationale). Override `handleMethodArgumentNotValid`:
|
||||
|
||||
```java
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request) {
|
||||
|
||||
ProblemDetail problemDetail = ex.getBody();
|
||||
problemDetail.setTitle("Validation Failed");
|
||||
problemDetail.setType(URI.create("urn:problem-type:validation-error"));
|
||||
|
||||
List<Map<String, String>> fieldErrors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.map(fe -> Map.of(
|
||||
"field", fe.getField(),
|
||||
"message", fe.getDefaultMessage()
|
||||
))
|
||||
.toList();
|
||||
|
||||
problemDetail.setProperty("fieldErrors", fieldErrors);
|
||||
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
||||
}
|
||||
```
|
||||
|
||||
Resulting response:
|
||||
```json
|
||||
{
|
||||
"type": "urn:problem-type:validation-error",
|
||||
"title": "Validation Failed",
|
||||
"status": 400,
|
||||
"detail": "Invalid request content.",
|
||||
"instance": "/api/events",
|
||||
"fieldErrors": [
|
||||
{ "field": "title", "message": "must not be blank" },
|
||||
{ "field": "expiryDate", "message": "must be a future date" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAPI Schema Definition
|
||||
|
||||
```yaml
|
||||
components:
|
||||
schemas:
|
||||
ProblemDetail:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
format: uri
|
||||
default: "about:blank"
|
||||
title:
|
||||
type: string
|
||||
status:
|
||||
type: integer
|
||||
detail:
|
||||
type: string
|
||||
instance:
|
||||
type: string
|
||||
format: uri
|
||||
additionalProperties: true
|
||||
|
||||
ValidationProblemDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ProblemDetail'
|
||||
- type: object
|
||||
properties:
|
||||
fieldErrors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- field
|
||||
- message
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Validation failed
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationProblemDetail'
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetail'
|
||||
```
|
||||
|
||||
Use media type `application/problem+json` in response definitions. Set `additionalProperties: true` on the base schema.
|
||||
|
||||
### Frontend Consumption (openapi-fetch)
|
||||
|
||||
openapi-fetch uses a discriminated union for responses:
|
||||
|
||||
```typescript
|
||||
const { data, error } = await client.POST('/api/events', { body: eventData })
|
||||
|
||||
if (error) {
|
||||
// `error` is typed from the OpenAPI error response schema
|
||||
console.log(error.title) // "Validation Failed"
|
||||
console.log(error.fieldErrors) // [{ field: "title", message: "..." }]
|
||||
return
|
||||
}
|
||||
|
||||
// `data` is the typed success response
|
||||
```
|
||||
|
||||
The `error` object is already typed from the generated schema — no manual type assertions needed for defined error shapes.
|
||||
|
||||
### Known Pitfalls
|
||||
|
||||
| Pitfall | Description | Mitigation |
|
||||
|---------|-------------|------------|
|
||||
| **Inconsistent formats** | Exceptions escaping to Spring Boot's `BasicErrorController` return legacy format (`timestamp`, `error`, `path`), not Problem Details. | Add a catch-all `@ExceptionHandler(Exception.class)` in the `@RestControllerAdvice`. |
|
||||
| **`server.error.include-*` ignored** | When Problem Details is active, these properties have no effect. | Control content via `ProblemDetail` directly. |
|
||||
| **Validation errors hidden by default** | Spring returns only `"Invalid request content."` without field details. | Override `handleMethodArgumentNotValid` explicitly. |
|
||||
| **Content negotiation** | `application/problem+json` is only returned when the client accepts it. `openapi-fetch` sends `Accept: application/json` which Spring considers compatible. | No action needed for SPA clients. |
|
||||
| **`about:blank` semantics** | With `type: "about:blank"`, `title` must match the HTTP status phrase. Custom titles require a custom `type` URI. | Use `urn:problem-type:*` URIs for custom problem types. |
|
||||
|
||||
## Sources
|
||||
|
||||
- [RFC 9457 Full Text](https://www.rfc-editor.org/rfc/rfc9457.html)
|
||||
- [Spring Framework Docs: Error Responses](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html)
|
||||
- [Swagger Blog: Problem Details RFC 9457](https://swagger.io/blog/problem-details-rfc9457-doing-api-errors-well/)
|
||||
- [Baeldung: Returning Errors Using ProblemDetail](https://www.baeldung.com/spring-boot-return-errors-problemdetail)
|
||||
- [SivaLabs: Spring Boot 3 Error Reporting](https://www.sivalabs.in/blog/spring-boot-3-error-reporting-using-problem-details/)
|
||||
- [Spring Boot Issue #43850: Render global errors as Problem Details](https://github.com/spring-projects/spring-boot/issues/43850)
|
||||
404
docs/agents/research/2026-03-04-sans-serif-fonts.md
Normal file
404
docs/agents/research/2026-03-04-sans-serif-fonts.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Research: Modern Sans-Serif Fonts for Mobile-First PWA
|
||||
|
||||
**Date:** 2026-03-04
|
||||
**Context:** Selecting a primary typeface for fete, a privacy-focused PWA for event announcements and RSVPs. The font must be open-source with permissive licensing, modern geometric/neo-grotesque style, excellent mobile readability, and strong weight range.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Based on research of 9 candidate fonts, **6 meet all requirements** for self-hosting and redistribution under permissive licenses. Two do not qualify:
|
||||
|
||||
- **General Sans**: Proprietary (ITF Free Font License, non-commercial personal use only)
|
||||
- **Satoshi**: License ambiguity; sources conflict between full OFL and ITF restrictions
|
||||
|
||||
The remaining **6 fonts are fully open-source** and suitable for the project:
|
||||
|
||||
| Font | License | Design | Weights | Status |
|
||||
|------|---------|--------|---------|--------|
|
||||
| Inter | OFL-1.1 | Neo-grotesque, humanist | 9 (Thin–Black) | ✅ Recommended |
|
||||
| Plus Jakarta Sans | OFL-1.1 | Geometric, modern | 7 (ExtraLight–ExtraBold) | ✅ Recommended |
|
||||
| Outfit | OFL-1.1 | Geometric | 9 (Thin–Black) | ✅ Recommended |
|
||||
| Space Grotesk | OFL-1.1 | Neo-grotesque, distinctive | 5 (Light–Bold) | ✅ Recommended |
|
||||
| Manrope | OFL-1.1 | Geometric, humanist | 7 (ExtraLight–ExtraBold) | ✅ Recommended |
|
||||
| DM Sans | OFL-1.1 | Geometric, low-contrast | 9 (Thin–Black) | ✅ Recommended |
|
||||
| Sora | OFL-1.1 | Geometric | 8 (Thin–ExtraBold) | ✅ Recommended |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Candidate Analysis
|
||||
|
||||
### 1. Inter
|
||||
|
||||
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||
|
||||
**Download Location:**
|
||||
- **Official:** https://github.com/rsms/inter (releases page)
|
||||
- **NPM:** `inter-ui` package
|
||||
- **Homebrew:** `font-inter`
|
||||
- **Official CDN:** https://rsms.me/inter/inter.css
|
||||
|
||||
**Design Character:** Neo-grotesque with humanist touches. High x-height for enhanced legibility on screens. Geometric letterforms with open apertures. Designed specifically for UI and on-screen use.
|
||||
|
||||
**Available Weights:** 9 weights from Thin (100) to Black (900), each with italic variant. Also available as a variable font with weight axis.
|
||||
|
||||
**Notable Apps/Products:**
|
||||
- **UX/Design tools:** Figma, Notion, Pixar Presto
|
||||
- **OS:** Elementary OS, GNOME
|
||||
- **Web:** GitLab, ISO, Mozilla, NASA
|
||||
- **Why:** Chosen by product teams valuing clarity and modern minimalism; default choice for UI designers
|
||||
|
||||
**Mobile Suitability:** Excellent. Specifically engineered for screen readability with high x-height and open apertures. Performs well at 14–16px body text.
|
||||
|
||||
**Distinctive Strengths:**
|
||||
- Purpose-built for digital interfaces
|
||||
- Exceptional clarity in dense UI layouts
|
||||
- Strong brand identity (recognizable across tech products)
|
||||
- Extensive OpenType features
|
||||
|
||||
**Weakness:** Very widely used; less distinctive for a bold brand identity. Considered the "safe" choice.
|
||||
|
||||
---
|
||||
|
||||
### 2. Plus Jakarta Sans
|
||||
|
||||
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||
|
||||
**Download Location:**
|
||||
- **Official Repository:** https://github.com/tokotype/PlusJakartaSans
|
||||
- **Source Files:** `sources/`, compiled fonts in `fonts/` directory
|
||||
- **Designer Contact:** mail@tokotype.com (Gumpita Rahayu, Tokotype)
|
||||
- **Latest Version:** 2.7.1 (May 2023)
|
||||
- **Build Command:** `gftools builder sources/builder.yaml`
|
||||
|
||||
**Design Character:** Geometric sans-serif with modern, clean-cut forms. Inspired by Neuzeit Grotesk and Futura but with contemporary refinement. Slightly taller x-height for clear spacing between caps and lowercase. Open counters and balanced spacing for legibility across sizes. **Bold, distinctive look** with personality.
|
||||
|
||||
**Available Weights:** 7 weights from ExtraLight (200) to ExtraBold (800), with matching italics.
|
||||
|
||||
**Notable Apps/Products:**
|
||||
- Original commission: Jakarta Provincial Government's "+Jakarta City of Collaboration" program (2020)
|
||||
- Now widely used in: Branding projects, modern web design, UI design
|
||||
- **Why:** Chosen for fresh, contemporary feel without generic blandness
|
||||
|
||||
**Mobile Suitability:** Excellent. Designed with mobile UI in mind. Clean letterforms render crisply on small screens.
|
||||
|
||||
**Distinctive Strengths:**
|
||||
- **Stylistic sets:** Sharp, Straight, and Swirl variants add design flexibility
|
||||
- Modern geometric with Indonesian design heritage (unique perspective)
|
||||
- Excellent for branding (not generic like Inter)
|
||||
- OpenType features for sophisticated typography
|
||||
- Well-maintained, active development
|
||||
|
||||
**Weakness:** Less ubiquitous than Inter; smaller ecosystem of design tool integrations.
|
||||
|
||||
---
|
||||
|
||||
### 3. Outfit
|
||||
|
||||
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||
|
||||
**Download Location:**
|
||||
- **Official Repository:** https://github.com/Outfitio/Outfit-Fonts
|
||||
- **Fonts Directory:** `/fonts` in repository
|
||||
- **OFL Text:** `OFL.txt` in repository
|
||||
- **Designer:** Rodrigo Fuenzalida (originally for Outfit.io)
|
||||
- **Status:** Repository archived Feb 25, 2025 (read-only, downloads remain accessible)
|
||||
|
||||
**Design Character:** Geometric sans-serif with warm, friendly appearance. Generous x-height, balanced spacing, low contrast. Nine static weights plus variable font with weight axis.
|
||||
|
||||
**Available Weights:** 9 weights from Thin (100) to Black (900). No italics.
|
||||
|
||||
**Notable Apps/Products:**
|
||||
- Originally created for Outfit.io platform
|
||||
- Good readability for body text (≈16px) and strong headline presence
|
||||
- Used in design tools (Figma integration)
|
||||
|
||||
**Mobile Suitability:** Good. Geometric forms and generous spacing work well on mobile, though low contrast may require careful pairing with sufficient color contrast.
|
||||
|
||||
**Distinctive Strengths:**
|
||||
- Full weight range (Thin–Black)
|
||||
- Variable font option for granular weight control
|
||||
- Stylistic alternates and rare ligatures
|
||||
- Accessible character set
|
||||
|
||||
**Weakness:** Archived repository; no active development. Low contrast design requires careful color/contrast pairing for accessibility.
|
||||
|
||||
---
|
||||
|
||||
### 4. Space Grotesk
|
||||
|
||||
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||
|
||||
**Download Location:**
|
||||
- **Official Repository:** https://github.com/floriankarsten/space-grotesk
|
||||
- **Official Site:** https://fonts.floriankarsten.com/space-grotesk
|
||||
- **Designer:** Florian Karsten
|
||||
- **Variants:** Variable font with weight axis
|
||||
|
||||
**Design Character:** Neo-grotesque with distinctive personality. Proportional variant of Space Mono (Colophon Foundry, 2016). Retains Space Mono's idiosyncratic details while optimizing for improved readability. Bold, tech-forward aesthetic with monowidth heritage visible in character design.
|
||||
|
||||
**Available Weights:** 5 weights—Light (300), Regular (400), Medium (500), SemiBold (600), Bold (700). No italics.
|
||||
|
||||
**Notable Apps/Products:**
|
||||
- Modern tech companies and startups seeking distinctive branding
|
||||
- Popular in neo-brutalist web design
|
||||
- Good for headlines and display use
|
||||
|
||||
**Mobile Suitability:** Good. Clean proportional forms with distinctive character. Works well for headlines; body text at 14px+ is readable.
|
||||
|
||||
**Distinctive Strengths:**
|
||||
- **Bold, tech-forward personality** — immediately recognizable
|
||||
- Heritage from Space Mono adds character without looking dated
|
||||
- Excellent OpenType support (old-style figures, tabular figures, superscript, subscript, fractions, stylistic alternates)
|
||||
- **Supports extended language coverage:** Latin, Vietnamese, Pinyin, Central/South-Eastern European
|
||||
|
||||
**Weakness:** Only 5 weights (lightest is 300, no Thin). Fewer weight options than Inter or DM Sans.
|
||||
|
||||
---
|
||||
|
||||
### 5. Manrope
|
||||
|
||||
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||
|
||||
**Download Location:**
|
||||
- **Official Repository:** https://github.com/sharanda/manrope
|
||||
- **Designer:** Mikhail Sharanda (2018), converted to variable by Mirko Velimirovic (2019)
|
||||
- **Alternative Sources:** Multiple community forks on GitHub, npm packages
|
||||
- **NPM Package:** `@fontsource/manrope`, `@fontsource-variable/manrope`
|
||||
|
||||
**Design Character:** Modern geometric sans-serif blending geometric shapes with humanistic elements. Semi-condensed structure with clean, contemporary feel. Geometric digits, packed with OpenType features.
|
||||
|
||||
**Available Weights:** 7 weights from ExtraLight (200) to ExtraBold (800). Available as variable font.
|
||||
|
||||
**Notable Apps/Products:**
|
||||
- Widely used in modern design systems
|
||||
- Popular in product/SaaS design
|
||||
- Good for both UI and branding
|
||||
|
||||
**Mobile Suitability:** Excellent. Clean geometric design with humanistic touches; balanced proportions work well on mobile.
|
||||
|
||||
**Distinctive Strengths:**
|
||||
- Geometric + humanistic blend (best of both worlds)
|
||||
- Well-maintained active project
|
||||
- Variable font available
|
||||
- Strong design community around the font
|
||||
|
||||
**Weakness:** None significant; solid all-around choice.
|
||||
|
||||
---
|
||||
|
||||
### 6. DM Sans
|
||||
|
||||
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||
|
||||
**Download Location:**
|
||||
- **Official Repository:** https://github.com/googlefonts/dm-fonts
|
||||
- **Releases Page:** https://github.com/googlefonts/dm-fonts/releases
|
||||
- **Google Fonts:** https://fonts.google.com/specimen/DM+Sans
|
||||
- **Design:** Commissioned from Colophon Foundry; Creative Direction: MultiAdaptor & DeepMind
|
||||
|
||||
**Design Character:** Low-contrast geometric sans-serif optimized for text at smaller sizes. Part of the DM suite (DM Sans, DM Serif Text, DM Serif Display). Designed for clarity and efficiency in dense typography.
|
||||
|
||||
**Available Weights:** 9 weights from Thin (100) to Black (900), each with italic variant.
|
||||
|
||||
**Notable Apps/Products:**
|
||||
- DeepMind products (by commission)
|
||||
- Tech companies favoring geometric clarity
|
||||
- Professional and commercial products requiring text legibility
|
||||
|
||||
**Mobile Suitability:** Excellent. Specifically optimized for small text sizes; low contrast minimizes visual noise on mobile screens.
|
||||
|
||||
**Distinctive Strengths:**
|
||||
- **Optimized for small text** — superior at 12–14px
|
||||
- Full weight range (Thin–Black)
|
||||
- Active Google Fonts maintenance
|
||||
- Italic variants (unlike Outfit or Space Grotesk)
|
||||
- Commissioned by reputable team (DeepMind)
|
||||
|
||||
**Weakness:** Low contrast may feel less bold on headlines without careful sizing/weight adjustment.
|
||||
|
||||
---
|
||||
|
||||
### 7. Sora
|
||||
|
||||
**License:** SIL Open Font License 1.1 (OFL-1.1)
|
||||
|
||||
**Download Location:**
|
||||
- **Official Repository:** https://github.com/sora-xor/sora-font
|
||||
- **GitHub Releases:** Direct TTF/OTF downloads available
|
||||
- **NPM Packages:** `@fontsource/sora`, `@fontsource-variable/sora`
|
||||
- **Original Purpose:** Custom typeface for SORA decentralized autonomous economy
|
||||
|
||||
**Design Character:** Geometric sans-serif with contemporary, clean aesthetic. Available as both static fonts and variable font. Designed as a branding solution for decentralized systems.
|
||||
|
||||
**Available Weights:** 8 weights from Thin (100) to ExtraBold (800), each with italic variant. Variable font available.
|
||||
|
||||
**Notable Apps/Products:**
|
||||
- Sora (XOR) decentralized projects
|
||||
- Crypto/blockchain projects using modern typography
|
||||
- Web3 products seeking distinctive branding
|
||||
|
||||
**Mobile Suitability:** Good. Clean geometric forms render well on mobile; italics available for emphasis.
|
||||
|
||||
**Distinctive Strengths:**
|
||||
- Full weight range with italics
|
||||
- Variable font option
|
||||
- Designed for digital-first branding
|
||||
- GitHub-native distribution
|
||||
|
||||
**Weakness:** Less established than Inter or DM Sans in mainstream product design; smaller ecosystem.
|
||||
|
||||
---
|
||||
|
||||
## Rejected Candidates
|
||||
|
||||
### General Sans
|
||||
|
||||
**Status:** ❌ Does not meet licensing requirements
|
||||
|
||||
**License:** ITF Free Font License (proprietary, non-commercial personal use only)
|
||||
|
||||
**Why Rejected:** This is a **paid commercial font** distributed by the Indian Type Foundry (not open-source). The ITF Free Font License permits personal use only; commercial use requires a separate paid license. Does not meet the "open-source with permissive license" requirement.
|
||||
|
||||
**Designer:** Frode Helland (published by Indian Type Foundry)
|
||||
|
||||
---
|
||||
|
||||
### Satoshi
|
||||
|
||||
**Status:** ⚠️ License ambiguity — conflicting sources
|
||||
|
||||
**Documented License:**
|
||||
- Some sources claim SIL Open Font License (OFL-1.1)
|
||||
- Other sources indicate ITF Free Font License (personal use only) similar to General Sans
|
||||
|
||||
**Design:** Swiss-style modernist sans-serif (Light to Black, 5–10 weights)
|
||||
|
||||
**Download:** Fontshare (Indian Type Foundry's free font service)
|
||||
|
||||
**Why Not Recommended:** The license status is unclear. While Fontshare advertises "free for personal and commercial use," the font's origin (Indian Type Foundry) and conflicting license documentation create uncertainty. For a privacy-focused project with clear open-source requirements, Satoshi's ambiguous licensing creates unnecessary legal risk. Better alternatives with unambiguous OFL-1.1 licensing are available.
|
||||
|
||||
**Recommendation:** If clarity is needed, contact Fontshare/ITF directly. For now, exclude from consideration to reduce licensing complexity.
|
||||
|
||||
---
|
||||
|
||||
## Comparative Table: Qualified Fonts
|
||||
|
||||
| Metric | Inter | Plus Jakarta Sans | Outfit | Space Grotesk | Manrope | DM Sans | Sora |
|
||||
|--------|-------|-------------------|--------|---------------|---------|---------|------|
|
||||
| **License** | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 | OFL-1.1 |
|
||||
| **Weights** | 9 | 7 | 9 | 5 | 7 | 9 | 8 |
|
||||
| **Italics** | ✅ Yes | ✅ Yes | ❌ No | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
|
||||
| **Variable Font** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
|
||||
| **Design** | Neo-grotesque | Geometric | Geometric | Neo-grotesque | Geo + Humanist | Geometric | Geometric |
|
||||
| **Personality** | Generic/Safe | Bold/Fresh | Warm/Friendly | Tech-Forward | Balanced | Efficient/Clean | Contemporary |
|
||||
| **Mobile Text** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **Distinctiveness** | Low | High | Medium | High | High | Medium | Medium |
|
||||
| **Ecosystem** | Very Large | Growing | Medium | Growing | Growing | Large | Small |
|
||||
| **Active Dev** | ✅ Yes | ✅ Yes | ❌ Archived | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Bold App-Native Branding
|
||||
|
||||
**Primary Choice: Plus Jakarta Sans**
|
||||
|
||||
**Rationale:**
|
||||
- Fully open-source (OFL-1.1) with unambiguous licensing
|
||||
- Bold, modern geometric aesthetic suitable for app branding
|
||||
- Stylistic sets (Sharp, Straight, Swirl) provide design flexibility
|
||||
- Well-maintained by Tokotype with clear development history
|
||||
- Strong presence in modern UI/web design
|
||||
- Excellent mobile readability with thoughtful character spacing
|
||||
- Indonesian design heritage adds unique perspective (not generic)
|
||||
|
||||
**Alternative: Space Grotesk**
|
||||
|
||||
If you prefer **even more distinctive character:**
|
||||
- Neo-grotesque with tech-forward personality
|
||||
- Smaller weight range (5 weights) but strong identity
|
||||
- Popular in contemporary design circles
|
||||
- Good for headlines; pair with a more neutral font for body text if needed
|
||||
|
||||
---
|
||||
|
||||
### For Safe, Professional UI
|
||||
|
||||
**Primary Choice: Inter or DM Sans**
|
||||
|
||||
**Inter if:**
|
||||
- Maximum ecosystem and tool support desired
|
||||
- Designing for broad recognition and trust
|
||||
- Team already familiar with Inter (widespread in tech)
|
||||
|
||||
**DM Sans if:**
|
||||
- Emphasis on small text legibility (optimized for 12–14px)
|
||||
- Prefer italic variants
|
||||
- Want active maintenance from Google Fonts community
|
||||
|
||||
---
|
||||
|
||||
### For Balanced Approach
|
||||
|
||||
**Manrope**
|
||||
|
||||
- Geometric + humanistic blend (versatile)
|
||||
- Excellent mobile performance
|
||||
- Strong weight range (7 weights)
|
||||
- Underrated choice; often overlooked for bolder options but delivers polish
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes for Self-Hosting
|
||||
|
||||
All recommended fonts can be self-hosted:
|
||||
|
||||
1. **Download:** Clone repository or download from releases page
|
||||
2. **Generate Web Formats:** Use FontForge, FontTools, or online converters to generate WOFF2 (required for modern browsers)
|
||||
3. **CSS:** Include via `@font-face` with local file paths
|
||||
4. **License:** Include `LICENSE.txt` or `OFL.txt` in the distribution
|
||||
|
||||
Example self-hosted CSS:
|
||||
```css
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
src: url('/fonts/PlusJakartaSans-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy Considerations
|
||||
|
||||
All selected fonts are self-hosted open-source projects with no telemetry, no external CDN dependencies, and no tracking. Fully compliant with the project's privacy-first principles.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Inter, Plus Jakarta Sans, and Space Grotesk** are the strongest candidates. The choice depends on brand positioning:
|
||||
|
||||
- **Generic + Safe → Inter**
|
||||
- **Bold + Modern → Plus Jakarta Sans**
|
||||
- **Tech-Forward + Distinctive → Space Grotesk**
|
||||
|
||||
All seven recommended fonts meet the strict licensing, openness, mobile readability, and weight-range requirements. Any of them are viable; the decision is primarily aesthetic.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Inter Font GitHub Repository](https://github.com/rsms/inter)
|
||||
- [Plus Jakarta Sans GitHub Repository](https://github.com/tokotype/PlusJakartaSans)
|
||||
- [Outfit Fonts GitHub Repository](https://github.com/Outfitio/Outfit-Fonts)
|
||||
- [Space Grotesk GitHub Repository](https://github.com/floriankarsten/space-grotesk)
|
||||
- [Manrope GitHub Repository](https://github.com/sharanda/manrope)
|
||||
- [DM Fonts GitHub Repository](https://github.com/googlefonts/dm-fonts)
|
||||
- [Sora Font GitHub Repository](https://github.com/sora-xor/sora-font)
|
||||
- [SIL Open Font License](https://openfontlicense.org/)
|
||||
- [Google Fonts (reference)](https://fonts.google.com)
|
||||
- [Fontshare (reference)](https://www.fontshare.com)
|
||||
195
docs/agents/research/2026-03-04-us1-create-event.md
Normal file
195
docs/agents/research/2026-03-04-us1-create-event.md
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
date: 2026-03-04T21:04:31+00:00
|
||||
git_commit: 747ed189456d2328147051bb8e7b3bbb43f47ea6
|
||||
branch: master
|
||||
topic: "US-1: Create an Event — Codebase Research"
|
||||
tags: [research, codebase, us-1, event-creation, hexagonal-architecture]
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Research: US-1 — Create an Event
|
||||
|
||||
## Research Question
|
||||
|
||||
What is the current state of the codebase relevant to implementing US-1 (Create an event)? What exists, what infrastructure is in place, and what needs to be built?
|
||||
|
||||
## Summary
|
||||
|
||||
US-1 is the first user story to be implemented. All setup tasks (T-1 through T-5) are complete. The codebase provides a hexagonal architecture skeleton with ArchUnit enforcement, an API-first workflow (OpenAPI spec → generated interfaces + TypeScript types), Liquibase migration tooling with an empty baseline, Testcontainers for integration tests, and a Vue 3 SPA frontend with typed API client. No domain models, use cases, persistence adapters, or controllers exist yet — the entire business logic layer is empty and waiting for US-1.
|
||||
|
||||
## US-1 Acceptance Criteria (from spec/userstories.md:21-40)
|
||||
|
||||
- [ ] Organizer fills in: title (required), description (optional), date/time (required), location (optional), expiry date (required)
|
||||
- [ ] Server stores event, returns event token (UUID) + organizer token (UUID) in creation response
|
||||
- [ ] Organizer redirected to event page after creation
|
||||
- [ ] Organizer token stored in localStorage for organizer access on this device
|
||||
- [ ] Event token, title, date stored in localStorage for local overview (US-7)
|
||||
- [ ] No account, login, or personal data required
|
||||
- [ ] Expiry date is mandatory, cannot be left blank
|
||||
- [ ] Event not discoverable except via direct link
|
||||
|
||||
Dependencies: T-4 (complete).
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### 1. Backend Architecture Skeleton
|
||||
|
||||
The hexagonal architecture is fully scaffolded but empty. All business-logic packages contain only `package-info.java` documentation files:
|
||||
|
||||
| Package | Location | Status |
|
||||
|---------|----------|--------|
|
||||
| `de.fete.domain.model` | `backend/src/main/java/de/fete/domain/model/` | Empty — domain entities go here |
|
||||
| `de.fete.domain.port.in` | `backend/src/main/java/de/fete/domain/port/in/` | Empty — use case interfaces go here |
|
||||
| `de.fete.domain.port.out` | `backend/src/main/java/de/fete/domain/port/out/` | Empty — repository ports go here |
|
||||
| `de.fete.application.service` | `backend/src/main/java/de/fete/application/service/` | Empty — use case implementations go here |
|
||||
| `de.fete.adapter.in.web` | `backend/src/main/java/de/fete/adapter/in/web/` | Empty hand-written code — generated HealthApi interface exists in target/ |
|
||||
| `de.fete.adapter.out.persistence` | `backend/src/main/java/de/fete/adapter/out/persistence/` | Empty — JPA entities + Spring Data repos go here |
|
||||
|
||||
Architecture constraints are enforced by ArchUnit (`HexagonalArchitectureTest.java:1-63`):
|
||||
- Domain layer must not depend on adapters, application, config, or Spring
|
||||
- Inbound and outbound ports must be interfaces
|
||||
- Web adapter and persistence adapter must not depend on each other
|
||||
- Onion architecture layers validated via `onionArchitecture()` rule
|
||||
|
||||
### 2. OpenAPI Spec — Current State and Extension Point
|
||||
|
||||
The OpenAPI spec at `backend/src/main/resources/openapi/api.yaml:1-38` currently defines only the health check endpoint. US-1 requires adding:
|
||||
|
||||
- **New path:** `POST /events` — create event endpoint
|
||||
- **New schemas:** Request body (title, description, dateTime, location, expiryDate) and response (eventToken, organizerToken)
|
||||
- **Error responses:** RFC 9457 Problem Details format (see `docs/agents/research/2026-03-04-rfc9457-problem-details.md`)
|
||||
- **Server base:** Already set to `/api` (line 11), matching `WebConfig.java:19`
|
||||
|
||||
Generated code lands in `target/generated-sources/openapi/`:
|
||||
- Interfaces: `de.fete.adapter.in.web.api` — controller must implement generated interface
|
||||
- Models: `de.fete.adapter.in.web.model` — request/response DTOs
|
||||
|
||||
Frontend types are generated via `npm run generate:api` into `frontend/src/api/schema.d.ts`.
|
||||
|
||||
### 3. Web Configuration
|
||||
|
||||
`WebConfig.java:1-41` configures two things relevant to US-1:
|
||||
|
||||
1. **API prefix** (line 19): All `@RestController` beans are prefixed with `/api`. So the OpenAPI path `/events` becomes `/api/events` at runtime.
|
||||
2. **SPA fallback** (lines 23-39): Any non-API, non-static-asset request falls through to `index.html`. This means Vue Router handles client-side routes like `/events/:token`.
|
||||
|
||||
### 4. Database Infrastructure
|
||||
|
||||
**Liquibase** is configured in `application.properties:8`:
|
||||
```
|
||||
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
|
||||
```
|
||||
|
||||
The master changelog (`db.changelog-master.xml:1-10`) includes a single empty baseline (`000-baseline.xml:1-13`). US-1 needs a new migration file (e.g. `001-create-event-table.xml`) added to the master changelog.
|
||||
|
||||
**JPA** is configured with `ddl-auto=validate` (`application.properties:4`), meaning Hibernate validates entity mappings against the schema but never auto-creates tables. Liquibase is the sole schema management tool.
|
||||
|
||||
**PostgreSQL** connection is externalized via environment variables in `application-prod.properties:1-4`:
|
||||
```
|
||||
spring.datasource.url=${DATABASE_URL}
|
||||
spring.datasource.username=${DATABASE_USERNAME}
|
||||
spring.datasource.password=${DATABASE_PASSWORD}
|
||||
```
|
||||
|
||||
### 5. Test Infrastructure
|
||||
|
||||
**Backend:**
|
||||
- JUnit 5 + Spring Boot Test + MockMvc (see `FeteApplicationTest.java`)
|
||||
- Testcontainers PostgreSQL (`TestcontainersConfig.java:1-17`) — real database for integration tests
|
||||
- ArchUnit for architecture validation
|
||||
- Checkstyle (Google Checks) and SpotBugs configured as build plugins
|
||||
|
||||
**Frontend:**
|
||||
- Vitest with jsdom environment (`vitest.config.ts`)
|
||||
- `@vue/test-utils` for component testing
|
||||
- Single placeholder test exists (`HelloWorld.spec.ts`)
|
||||
- Test pattern: `src/**/__tests__/*.spec.ts`
|
||||
|
||||
### 6. Frontend — Router, API Client, and localStorage
|
||||
|
||||
**Router** (`frontend/src/router/index.ts:1-23`): Currently has two placeholder routes (`/` and `/about`). US-1 needs:
|
||||
- A route for the event creation form (e.g. `/create`)
|
||||
- A route for the event page (e.g. `/events/:token`) — needed for post-creation redirect
|
||||
|
||||
**API client** (`frontend/src/api/client.ts:1-4`): Singleton `openapi-fetch` client typed against generated schema. Base URL `/api`. Ready for use — just needs the new endpoints in the generated types.
|
||||
|
||||
**localStorage:** No utilities exist yet. The `composables/` directory contains only `.gitkeep`. US-1 needs:
|
||||
- A composable or utility for storing/retrieving organizer tokens per event
|
||||
- Storage of event token, title, and date for the local overview (US-7)
|
||||
|
||||
**Components:** Only Vue/Vite scaffold defaults (HelloWorld, TheWelcome, icons). All need to be replaced with the actual event creation form.
|
||||
|
||||
### 7. Token Model
|
||||
|
||||
The spec defines three token types (`userstories.md:12-18`):
|
||||
- **Event token**: Public UUID v4 in the event URL. Used by guests to access event pages.
|
||||
- **Organizer token**: Secret UUID v4 stored in localStorage. Used to authenticate organizer actions.
|
||||
- **Internal DB ID**: Never exposed — implementation detail only.
|
||||
|
||||
UUID v4 (random) is used for both tokens. KISS — no time-ordering (v7) needed for this use case. Generated server-side via `java.util.UUID.randomUUID()`.
|
||||
|
||||
### 8. Cross-Cutting Concerns
|
||||
|
||||
- **Date/time handling:** See `docs/agents/research/2026-03-04-datetime-best-practices.md` for the full stack-wide type mapping. Event dateTime → `OffsetDateTime` / `timestamptz`. Expiry date → `LocalDate` / `date`.
|
||||
- **Error responses:** RFC 9457 Problem Details format. See `docs/agents/research/2026-03-04-rfc9457-problem-details.md`.
|
||||
- **Honeypot fields:** Removed from scope — overengineered for this project.
|
||||
|
||||
## Code References
|
||||
|
||||
- `spec/userstories.md:21-40` — US-1 full specification
|
||||
- `spec/implementation-phases.md:7` — US-1 is first in implementation order
|
||||
- `backend/src/main/resources/openapi/api.yaml:1-38` — OpenAPI spec (extension point)
|
||||
- `backend/src/main/java/de/fete/config/WebConfig.java:19` — API prefix `/api`
|
||||
- `backend/src/main/java/de/fete/config/WebConfig.java:23-39` — SPA fallback routing
|
||||
- `backend/src/main/resources/application.properties:4` — JPA ddl-auto=validate
|
||||
- `backend/src/main/resources/application.properties:8` — Liquibase changelog config
|
||||
- `backend/src/main/resources/db/changelog/db.changelog-master.xml:8` — Single include, extend here
|
||||
- `backend/src/main/resources/db/changelog/000-baseline.xml:8-10` — Empty baseline changeset
|
||||
- `backend/src/main/resources/application-prod.properties:1-4` — DB env vars
|
||||
- `backend/src/test/java/de/fete/HexagonalArchitectureTest.java:1-63` — Architecture constraints
|
||||
- `backend/src/test/java/de/fete/TestcontainersConfig.java:1-17` — Test DB container
|
||||
- `frontend/src/router/index.ts:1-23` — Vue Router (extend with event routes)
|
||||
- `frontend/src/api/client.ts:1-4` — API client (ready to use with generated types)
|
||||
- `frontend/src/composables/.gitkeep` — Empty composables directory
|
||||
|
||||
## Architecture Documentation
|
||||
|
||||
### Hexagonal Layer Mapping for US-1
|
||||
|
||||
| Layer | Package | US-1 Artifacts |
|
||||
|-------|---------|----------------|
|
||||
| **Domain Model** | `de.fete.domain.model` | `Event` entity (title, description, dateTime, location, expiryDate, eventToken, organizerToken, createdAt) |
|
||||
| **Inbound Port** | `de.fete.domain.port.in` | `CreateEventUseCase` interface |
|
||||
| **Outbound Port** | `de.fete.domain.port.out` | `EventRepository` interface (save, findByToken) |
|
||||
| **Application Service** | `de.fete.application.service` | `EventService` implementing `CreateEventUseCase` |
|
||||
| **Web Adapter** | `de.fete.adapter.in.web` | Controller implementing generated `EventsApi` interface |
|
||||
| **Persistence Adapter** | `de.fete.adapter.out.persistence` | JPA entity + Spring Data repository implementing `EventRepository` port |
|
||||
| **Config** | `de.fete.config` | (existing WebConfig sufficient) |
|
||||
|
||||
### API-First Flow
|
||||
|
||||
```
|
||||
api.yaml (edit) → mvn compile → HealthApi.java + EventsApi.java (generated)
|
||||
HealthResponse.java + CreateEventRequest.java + CreateEventResponse.java (generated)
|
||||
→ npm run generate:api → schema.d.ts (generated TypeScript types)
|
||||
```
|
||||
|
||||
The hand-written controller in `adapter.in.web` implements the generated interface. The frontend uses the generated types via `openapi-fetch`.
|
||||
|
||||
### Database Schema Required
|
||||
|
||||
US-1 needs a single `events` table with columns mapping to the domain model. The migration file goes into `db/changelog/` and must be included in `db.changelog-master.xml`.
|
||||
|
||||
### Frontend Data Flow
|
||||
|
||||
```
|
||||
EventCreateForm.vue → api.post('/events', body) → backend
|
||||
← { eventToken, organizerToken }
|
||||
→ localStorage.setItem (organizer token, event meta)
|
||||
→ router.push(`/events/${eventToken}`)
|
||||
```
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
- **Expiry date validation at creation:** Yes — the server enforces that the expiry date is in the future at creation time, not only at edit time (US-5). Rationale: an event should never exist in an invalid state. If it's never edited, a past expiry date would be nonsensical. This extends US-1 AC7 beyond "mandatory" to "mandatory and in the future".
|
||||
- **Event page after creation:** Option A — create a minimal stub route (`/events/:token`) with a placeholder view (e.g. "Event created" confirmation). The full event page is built in US-2. This keeps story boundaries clean while satisfying US-1 AC3 (redirect after creation).
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -37,3 +37,4 @@ __screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
.rodney/
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<title>fete</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,85 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
<div class="app-container">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
BIN
frontend/src/assets/fonts/Sora-Variable.woff2
Normal file
BIN
frontend/src/assets/fonts/Sora-Variable.woff2
Normal file
Binary file not shown.
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,35 +1,181 @@
|
||||
@import './base.css';
|
||||
@font-face {
|
||||
font-family: 'Sora';
|
||||
src: url('@/assets/fonts/Sora-Variable.woff2') format('woff2');
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Colors: Electric Dusk */
|
||||
--color-gradient-start: #f06292;
|
||||
--color-gradient-mid: #ab47bc;
|
||||
--color-gradient-end: #5c6bc0;
|
||||
--color-accent: #ff7043;
|
||||
--color-text: #1c1c1e;
|
||||
--color-text-on-gradient: #ffffff;
|
||||
--color-surface: #fff5f8;
|
||||
--color-card: #ffffff;
|
||||
|
||||
/* Gradient */
|
||||
--gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.5rem;
|
||||
--spacing-sm: 0.75rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.2rem;
|
||||
--spacing-xl: 1.5rem;
|
||||
--spacing-2xl: 2rem;
|
||||
|
||||
/* Borders */
|
||||
--radius-card: 14px;
|
||||
--radius-button: 14px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* Layout */
|
||||
--content-max-width: 480px;
|
||||
--content-padding: 1.2rem;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: 'Sora', system-ui, -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--gradient-primary);
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
.app-container {
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
min-height: 100vh;
|
||||
padding: var(--content-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Card-style form fields */
|
||||
.form-field {
|
||||
background: var(--color-card);
|
||||
border: none;
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--spacing-md) var(--spacing-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.form-field:focus {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.form-field::placeholder {
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
textarea.form-field {
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
/* Form group (label + input) */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Primary action button */
|
||||
.btn-primary {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-button);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-button);
|
||||
transition: opacity 0.2s ease, transform 0.1s ease;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.field-error {
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
||||
>Vue - Official</a
|
||||
>. If you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import HelloWorld from '../HelloWorld.vue'
|
||||
|
||||
describe('HelloWorld', () => {
|
||||
it('renders properly', () => {
|
||||
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
|
||||
expect(wrapper.text()).toContain('Hello Vitest')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
119
frontend/src/composables/__tests__/useEventStorage.spec.ts
Normal file
119
frontend/src/composables/__tests__/useEventStorage.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useEventStorage } from '../useEventStorage'
|
||||
|
||||
// jsdom provides a working localStorage in the window object
|
||||
// but Node's --localstorage-file warning can be ignored
|
||||
function clearStorage() {
|
||||
try {
|
||||
window.localStorage.setItem('fete:events', '[]')
|
||||
} catch {
|
||||
// Provide a minimal mock if localStorage is broken
|
||||
const store: Record<string, string> = {}
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, val: string) => {
|
||||
store[key] = val
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key]
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEventStorage', () => {
|
||||
beforeEach(() => {
|
||||
clearStorage()
|
||||
})
|
||||
|
||||
it('returns empty array when no events stored', () => {
|
||||
const { getStoredEvents } = useEventStorage()
|
||||
expect(getStoredEvents()).toEqual([])
|
||||
})
|
||||
|
||||
it('saves and retrieves a created event', () => {
|
||||
const { saveCreatedEvent, getStoredEvents } = useEventStorage()
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'abc-123',
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
const events = getStoredEvents()
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]!.eventToken).toBe('abc-123')
|
||||
expect(events[0]!.organizerToken).toBe('org-456')
|
||||
expect(events[0]!.title).toBe('Birthday')
|
||||
})
|
||||
|
||||
it('returns organizer token for known event', () => {
|
||||
const { saveCreatedEvent, getOrganizerToken } = useEventStorage()
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'abc-123',
|
||||
organizerToken: 'org-456',
|
||||
title: 'Test',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
expect(getOrganizerToken('abc-123')).toBe('org-456')
|
||||
})
|
||||
|
||||
it('returns undefined organizer token for unknown event', () => {
|
||||
const { getOrganizerToken } = useEventStorage()
|
||||
expect(getOrganizerToken('unknown')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stores multiple events independently', () => {
|
||||
const { saveCreatedEvent, getStoredEvents } = useEventStorage()
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'event-1',
|
||||
title: 'First',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'event-2',
|
||||
title: 'Second',
|
||||
dateTime: '2026-07-15T20:00:00+02:00',
|
||||
expiryDate: '2026-08-15',
|
||||
})
|
||||
|
||||
const events = getStoredEvents()
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events.map((e) => e.eventToken)).toContain('event-1')
|
||||
expect(events.map((e) => e.eventToken)).toContain('event-2')
|
||||
})
|
||||
|
||||
it('overwrites event with same token', () => {
|
||||
const { saveCreatedEvent, getStoredEvents } = useEventStorage()
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'abc-123',
|
||||
title: 'Old Title',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'abc-123',
|
||||
title: 'New Title',
|
||||
dateTime: '2026-06-15T20:00:00+02:00',
|
||||
expiryDate: '2026-07-15',
|
||||
})
|
||||
|
||||
const events = getStoredEvents()
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]!.title).toBe('New Title')
|
||||
})
|
||||
})
|
||||
41
frontend/src/composables/useEventStorage.ts
Normal file
41
frontend/src/composables/useEventStorage.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface StoredEvent {
|
||||
eventToken: string
|
||||
organizerToken?: string
|
||||
title: string
|
||||
dateTime: string
|
||||
expiryDate: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'fete:events'
|
||||
|
||||
function readEvents(): StoredEvent[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as StoredEvent[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeEvents(events: StoredEvent[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
|
||||
}
|
||||
|
||||
export function useEventStorage() {
|
||||
function saveCreatedEvent(event: StoredEvent): void {
|
||||
const events = readEvents().filter((e) => e.eventToken !== event.eventToken)
|
||||
events.push(event)
|
||||
writeEvents(events)
|
||||
}
|
||||
|
||||
function getStoredEvents(): StoredEvent[] {
|
||||
return readEvents()
|
||||
}
|
||||
|
||||
function getOrganizerToken(eventToken: string): string | undefined {
|
||||
const event = readEvents().find((e) => e.eventToken === eventToken)
|
||||
return event?.organizerToken
|
||||
}
|
||||
|
||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken }
|
||||
}
|
||||
@@ -10,12 +10,14 @@ const router = createRouter({
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AboutView.vue'),
|
||||
path: '/create',
|
||||
name: 'create-event',
|
||||
component: () => import('../views/EventCreateView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/events/:token',
|
||||
name: 'event',
|
||||
component: () => import('../views/EventStubView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
258
frontend/src/views/EventCreateView.vue
Normal file
258
frontend/src/views/EventCreateView.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<main class="create">
|
||||
<header class="create__header">
|
||||
<RouterLink to="/" class="create__back" aria-label="Back to home">←</RouterLink>
|
||||
<h1 class="create__title">Create</h1>
|
||||
</header>
|
||||
|
||||
<form class="create__form" novalidate @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
<label for="title" class="form-label">Title *</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
class="form-field"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="What's the event?"
|
||||
:aria-invalid="!!errors.title"
|
||||
:aria-describedby="errors.title ? 'title-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.title" id="title-error" class="field-error" role="alert">{{ errors.title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-field"
|
||||
maxlength="2000"
|
||||
placeholder="Tell people more about it…"
|
||||
:aria-invalid="!!errors.description"
|
||||
:aria-describedby="errors.description ? 'description-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.description" id="description-error" class="field-error" role="alert">{{ errors.description }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateTime" class="form-label">Date & Time *</label>
|
||||
<input
|
||||
id="dateTime"
|
||||
v-model="form.dateTime"
|
||||
type="datetime-local"
|
||||
class="form-field"
|
||||
required
|
||||
:aria-invalid="!!errors.dateTime"
|
||||
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.dateTime" id="dateTime-error" class="field-error" role="alert">{{ errors.dateTime }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location" class="form-label">Location</label>
|
||||
<input
|
||||
id="location"
|
||||
v-model="form.location"
|
||||
type="text"
|
||||
class="form-field"
|
||||
maxlength="500"
|
||||
placeholder="Where is it?"
|
||||
:aria-invalid="!!errors.location"
|
||||
:aria-describedby="errors.location ? 'location-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expiryDate" class="form-label">Expiry Date *</label>
|
||||
<input
|
||||
id="expiryDate"
|
||||
v-model="form.expiryDate"
|
||||
type="date"
|
||||
class="form-field"
|
||||
required
|
||||
:min="tomorrow"
|
||||
:aria-invalid="!!errors.expiryDate"
|
||||
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary" :disabled="submitting">
|
||||
{{ submitting ? 'Creating…' : 'Create Event' }}
|
||||
</button>
|
||||
|
||||
<p v-if="serverError" class="field-error text-center" role="alert">{{ serverError }}</p>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useEventStorage } from '@/composables/useEventStorage'
|
||||
|
||||
const router = useRouter()
|
||||
const { saveCreatedEvent } = useEventStorage()
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
dateTime: '',
|
||||
location: '',
|
||||
expiryDate: '',
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
dateTime: '',
|
||||
location: '',
|
||||
expiryDate: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
|
||||
const tomorrow = computed(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 1)
|
||||
return d.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
function clearErrors() {
|
||||
errors.title = ''
|
||||
errors.description = ''
|
||||
errors.dateTime = ''
|
||||
errors.location = ''
|
||||
errors.expiryDate = ''
|
||||
serverError.value = ''
|
||||
}
|
||||
|
||||
// Clear individual field errors when the user types
|
||||
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
|
||||
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
|
||||
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
|
||||
watch(() => form.description, () => { serverError.value = '' })
|
||||
watch(() => form.location, () => { serverError.value = '' })
|
||||
|
||||
function validate(): boolean {
|
||||
clearErrors()
|
||||
let valid = true
|
||||
|
||||
if (!form.title.trim()) {
|
||||
errors.title = 'Title is required.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.dateTime) {
|
||||
errors.dateTime = 'Date and time are required.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.expiryDate) {
|
||||
errors.expiryDate = 'Expiry date is required.'
|
||||
valid = false
|
||||
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
|
||||
errors.expiryDate = 'Expiry date must be in the future.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validate()) return
|
||||
|
||||
submitting.value = true
|
||||
|
||||
// Build ISO 8601 dateTime with local timezone offset
|
||||
const localDate = new Date(form.dateTime)
|
||||
const offsetMinutes = -localDate.getTimezoneOffset()
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-'
|
||||
const absOffset = Math.abs(offsetMinutes)
|
||||
const offsetHours = String(Math.floor(absOffset / 60)).padStart(2, '0')
|
||||
const offsetMins = String(absOffset % 60).padStart(2, '0')
|
||||
const dateTimeWithOffset = form.dateTime + ':00' + sign + offsetHours + ':' + offsetMins
|
||||
|
||||
try {
|
||||
const { data, error } = await api.POST('/events', {
|
||||
body: {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
dateTime: dateTimeWithOffset,
|
||||
location: form.location.trim() || undefined,
|
||||
expiryDate: form.expiryDate,
|
||||
},
|
||||
})
|
||||
|
||||
submitting.value = false
|
||||
|
||||
if (error) {
|
||||
if ('fieldErrors' in error && Array.isArray(error.fieldErrors)) {
|
||||
for (const fe of error.fieldErrors) {
|
||||
const field = fe.field as keyof typeof errors
|
||||
if (field in errors) {
|
||||
errors[field] = fe.message
|
||||
}
|
||||
}
|
||||
} else {
|
||||
serverError.value = error.detail || 'Something went wrong. Please try again.'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
saveCreatedEvent({
|
||||
eventToken: data.eventToken,
|
||||
organizerToken: data.organizerToken,
|
||||
title: data.title,
|
||||
dateTime: data.dateTime,
|
||||
expiryDate: data.expiryDate,
|
||||
})
|
||||
|
||||
router.push({ name: 'event', params: { token: data.eventToken } })
|
||||
}
|
||||
} catch {
|
||||
submitting.value = false
|
||||
serverError.value = 'Could not reach the server. Please try again.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.create__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.create__back {
|
||||
color: var(--color-text-on-gradient);
|
||||
font-size: 1.5rem;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.create__title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.create__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
</style>
|
||||
132
frontend/src/views/EventStubView.vue
Normal file
132
frontend/src/views/EventStubView.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<main class="stub">
|
||||
<header class="stub__header">
|
||||
<RouterLink to="/" class="stub__back" aria-label="Back to home">←</RouterLink>
|
||||
<span class="stub__brand">fete</span>
|
||||
</header>
|
||||
|
||||
<div class="stub__content">
|
||||
<p class="stub__check">✓ Event created!</p>
|
||||
|
||||
<p class="stub__share-label">Share this link:</p>
|
||||
<div class="stub__link-box">
|
||||
<span class="stub__link">{{ eventUrl }}</span>
|
||||
<button class="stub__copy" type="button" @click="copyLink" :aria-label="copyLabel">
|
||||
{{ copyState === 'copied' ? 'Copied!' : copyState === 'failed' ? 'Failed' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
|
||||
|
||||
const eventUrl = computed(() => {
|
||||
return window.location.origin + '/events/' + route.params.token
|
||||
})
|
||||
|
||||
const copyLabel = computed(() => {
|
||||
if (copyState.value === 'copied') return 'Link copied to clipboard'
|
||||
if (copyState.value === 'failed') return 'Copy failed — select the link to copy manually'
|
||||
return 'Copy event link to clipboard'
|
||||
})
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(eventUrl.value)
|
||||
copyState.value = 'copied'
|
||||
setTimeout(() => {
|
||||
copyState.value = 'idle'
|
||||
}, 2000)
|
||||
} catch {
|
||||
copyState.value = 'failed'
|
||||
setTimeout(() => {
|
||||
copyState.value = 'idle'
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xl);
|
||||
padding-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stub__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stub__back {
|
||||
color: var(--color-text-on-gradient);
|
||||
font-size: 1.5rem;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stub__brand {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.stub__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stub__check {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.stub__share-label {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-on-gradient);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stub__link-box {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.stub__link {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stub__copy {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-button);
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
<main class="home">
|
||||
<h1 class="home__title">fete</h1>
|
||||
<p class="home__subtitle">No events yet.<br />Create your first one!</p>
|
||||
<RouterLink to="/create" class="btn-primary home__cta">+ Create Event</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home__title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.home__subtitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-on-gradient);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.home__cta {
|
||||
margin-top: var(--spacing-md);
|
||||
max-width: 280px;
|
||||
}
|
||||
</style>
|
||||
|
||||
255
frontend/src/views/__tests__/EventCreateView.spec.ts
Normal file
255
frontend/src/views/__tests__/EventCreateView.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EventCreateView from '../EventCreateView.vue'
|
||||
import { api } from '@/api/client'
|
||||
vi.mock('@/api/client', () => ({
|
||||
api: {
|
||||
POST: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useEventStorage', () => ({
|
||||
useEventStorage: vi.fn(() => ({
|
||||
saveCreatedEvent: vi.fn(),
|
||||
getStoredEvents: vi.fn(() => []),
|
||||
getOrganizerToken: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||
{ path: '/create', name: 'create-event', component: EventCreateView },
|
||||
{ path: '/events/:token', name: 'event', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('EventCreateView', () => {
|
||||
it('renders all form fields', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('#title').exists()).toBe(true)
|
||||
expect(wrapper.find('#description').exists()).toBe(true)
|
||||
expect(wrapper.find('#dateTime').exists()).toBe(true)
|
||||
expect(wrapper.find('#location').exists()).toBe(true)
|
||||
expect(wrapper.find('#expiryDate').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has required attribute on required fields', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('#title').attributes('required')).toBeDefined()
|
||||
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
|
||||
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not have required attribute on optional fields', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('#description').attributes('required')).toBeUndefined()
|
||||
expect(wrapper.find('#location').attributes('required')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has a submit button', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.text()).toBe('Create Event')
|
||||
})
|
||||
|
||||
it('shows server error when network request fails', async () => {
|
||||
vi.mocked(api.POST).mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
// Fill required fields
|
||||
await wrapper.find('#title').setValue('My Event')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const alerts = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(alerts).toContain('Could not reach the server. Please try again.')
|
||||
|
||||
// Submit button should not remain disabled
|
||||
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears field error when user types into that field', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
// Submit empty form to trigger validation errors
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Type into title field
|
||||
await wrapper.find('#title').setValue('My Event')
|
||||
|
||||
// Title error should be cleared (span removed from DOM), but other errors should remain
|
||||
const titleError = wrapper.find('#title').element.closest('.form-group')!.querySelector('[role="alert"]')
|
||||
expect(titleError).toBeNull()
|
||||
|
||||
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||
expect(dateTimeError.textContent).not.toBe('')
|
||||
|
||||
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||
expect(expiryError.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('shows validation errors when submitting empty form', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
const errorElements = wrapper.findAll('[role="alert"]')
|
||||
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(errorTexts.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
||||
const mockSave = vi.fn()
|
||||
vi.mocked(vi.importActual<typeof import('@/composables/useEventStorage')>)
|
||||
const { useEventStorage } = await import('@/composables/useEventStorage')
|
||||
vi.mocked(useEventStorage).mockReturnValue({
|
||||
saveCreatedEvent: mockSave,
|
||||
getStoredEvents: vi.fn(() => []),
|
||||
getOrganizerToken: vi.fn(),
|
||||
})
|
||||
|
||||
vi.mocked(api.POST).mockResolvedValueOnce({
|
||||
data: {
|
||||
eventToken: 'abc-123',
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday Party',
|
||||
dateTime: '2026-12-25T18:00:00+01:00',
|
||||
expiryDate: '2026-12-24',
|
||||
},
|
||||
error: undefined,
|
||||
response: new Response(),
|
||||
})
|
||||
|
||||
const router = createTestRouter()
|
||||
const pushSpy = vi.spyOn(router, 'push')
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('#title').setValue('Birthday Party')
|
||||
await wrapper.find('#description').setValue('Come celebrate!')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#location').setValue('Berlin')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events', {
|
||||
body: expect.objectContaining({
|
||||
title: 'Birthday Party',
|
||||
description: 'Come celebrate!',
|
||||
location: 'Berlin',
|
||||
expiryDate: '2026-12-24',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(mockSave).toHaveBeenCalledWith({
|
||||
eventToken: 'abc-123',
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday Party',
|
||||
dateTime: '2026-12-25T18:00:00+01:00',
|
||||
expiryDate: '2026-12-24',
|
||||
})
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({
|
||||
name: 'event',
|
||||
params: { token: 'abc-123' },
|
||||
})
|
||||
})
|
||||
|
||||
it('displays server-side field errors on the correct fields', async () => {
|
||||
vi.mocked(api.POST).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: {
|
||||
fieldErrors: [{ field: 'title', message: 'Title already taken' }],
|
||||
},
|
||||
response: new Response(),
|
||||
} as ReturnType<typeof api.POST> extends Promise<infer R> ? R : never)
|
||||
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('#title').setValue('Duplicate Event')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const titleError = wrapper.find('#title-error')
|
||||
expect(titleError.exists()).toBe(true)
|
||||
expect(titleError.text()).toBe('Title already taken')
|
||||
|
||||
// Other field errors should not be present
|
||||
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
87
frontend/src/views/__tests__/EventStubView.spec.ts
Normal file
87
frontend/src/views/__tests__/EventStubView.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EventStubView from '../EventStubView.vue'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||
{ path: '/events/:token', name: 'event', component: EventStubView },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountWithToken(token = 'test-token-123') {
|
||||
const router = createTestRouter()
|
||||
await router.push(`/events/${token}`)
|
||||
await router.isReady()
|
||||
return mount(EventStubView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('EventStubView', () => {
|
||||
it('renders the event URL based on route param', async () => {
|
||||
const wrapper = await mountWithToken('abc-def')
|
||||
|
||||
const linkText = wrapper.find('.stub__link').text()
|
||||
expect(linkText).toContain('/events/abc-def')
|
||||
})
|
||||
|
||||
it('shows the correct share URL with origin', async () => {
|
||||
const wrapper = await mountWithToken('my-event-token')
|
||||
|
||||
const linkText = wrapper.find('.stub__link').text()
|
||||
expect(linkText).toBe(`${window.location.origin}/events/my-event-token`)
|
||||
})
|
||||
|
||||
it('has a copy button', async () => {
|
||||
const wrapper = await mountWithToken()
|
||||
|
||||
const copyBtn = wrapper.find('.stub__copy')
|
||||
expect(copyBtn.exists()).toBe(true)
|
||||
expect(copyBtn.text()).toBe('Copy')
|
||||
})
|
||||
|
||||
it('copies link to clipboard and shows confirmation', async () => {
|
||||
const writeTextMock = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: writeTextMock },
|
||||
})
|
||||
|
||||
const wrapper = await mountWithToken('copy-test')
|
||||
|
||||
await wrapper.find('.stub__copy').trigger('click')
|
||||
|
||||
expect(writeTextMock).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/events/copy-test`,
|
||||
)
|
||||
expect(wrapper.find('.stub__copy').text()).toBe('Copied!')
|
||||
})
|
||||
|
||||
it('shows failure message when clipboard is unavailable', async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Not allowed')) },
|
||||
})
|
||||
|
||||
const wrapper = await mountWithToken('fail-test')
|
||||
|
||||
await wrapper.find('.stub__copy').trigger('click')
|
||||
|
||||
expect(wrapper.find('.stub__copy').text()).toBe('Failed')
|
||||
expect(wrapper.find('.stub__copy').attributes('aria-label')).toBe(
|
||||
'Copy failed — select the link to copy manually',
|
||||
)
|
||||
})
|
||||
|
||||
it('has a back link to home', async () => {
|
||||
const wrapper = await mountWithToken()
|
||||
|
||||
const backLink = wrapper.find('.stub__back')
|
||||
expect(backLink.exists()).toBe(true)
|
||||
expect(backLink.attributes('aria-label')).toBe('Back to home')
|
||||
expect(backLink.attributes('href')).toBe('/')
|
||||
})
|
||||
})
|
||||
@@ -15,4 +15,12 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
85
spec/design-system.md
Normal file
85
spec/design-system.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Design System
|
||||
|
||||
This document defines the visual design language for fete. All frontend implementation must follow these specifications.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Mobile-first / App-native feel** — not a classic website. Think installed app, not browser page.
|
||||
- **Desktop:** centered narrow column (max ~480px), gradient background fills the rest.
|
||||
- **Generous whitespace** — elements breathe, nothing cramped.
|
||||
- **WCAG AA contrast** as baseline for all color choices.
|
||||
- **Accessibility is a baseline requirement** — not an afterthought (per project statutes).
|
||||
|
||||
## Color Palette: Electric Dusk
|
||||
|
||||
Chosen for best balance of style, broad appeal, and accessibility.
|
||||
|
||||
| Role | Hex | Description |
|
||||
|--------------------|-----------|--------------------|
|
||||
| Gradient Start | `#F06292` | Pink |
|
||||
| Gradient Mid | `#AB47BC` | Purple |
|
||||
| Gradient End | `#5C6BC0` | Indigo blue |
|
||||
| Accent (CTAs) | `#FF7043` | Deep orange |
|
||||
| Text (light mode) | `#1C1C1E` | Near black |
|
||||
| Text (dark mode) | `#FFFFFF` | White |
|
||||
| Surface (light) | `#FFF5F8` | Pinkish white |
|
||||
| Surface (dark) | `#1B1730` | Deep indigo-black |
|
||||
| Card (light) | `#FFFFFF` | White |
|
||||
| Card (dark) | `#2A2545` | Muted indigo |
|
||||
|
||||
### Primary Gradient
|
||||
|
||||
```css
|
||||
background: linear-gradient(135deg, #F06292 0%, #AB47BC 50%, #5C6BC0 100%);
|
||||
```
|
||||
|
||||
### Usage Rules
|
||||
|
||||
- Gradient for hero/splash areas and page backgrounds — not as direct text background for body copy.
|
||||
- Cards and content areas use solid surface colors with high-contrast text.
|
||||
- Accent color (`#FF7043`) for primary action buttons with dark text (`#1C1C1E`).
|
||||
- White text on gradient mid/end passes WCAG AA (4.82:1 and 4.86:1).
|
||||
- White text on gradient start passes AA-large (3.06:1) — use for headings 18px+ only.
|
||||
|
||||
## Typography: Sora
|
||||
|
||||
Contemporary geometric sans-serif with slightly rounded terminals. Modern and friendly without being childish.
|
||||
|
||||
- **Font:** Sora
|
||||
- **License:** SIL Open Font License 1.1 (OFL)
|
||||
- **Source:** https://github.com/sora-xor/sora-font
|
||||
- **Format:** Self-hosted WOFF2. No external CDN. No Google Fonts.
|
||||
- **Weights:** 400 (Regular), 500 (Medium), 600 (SemiBold), 700 (Bold), 800 (ExtraBold)
|
||||
|
||||
### Weight Usage
|
||||
|
||||
| Context | Weight | Size guideline |
|
||||
|------------------|--------|-----------------|
|
||||
| Body text | 400 | 0.85–1rem |
|
||||
| Labels | 600–700| 0.8–0.9rem |
|
||||
| Headlines | 700–800| 1.2–1.6rem |
|
||||
| Buttons | 700–800| 1rem |
|
||||
| Small/meta text | 400–500| 0.75–0.85rem |
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Card-Style Form Fields
|
||||
|
||||
- Rounded corners (`border-radius: 14px`)
|
||||
- Generous padding (`0.9rem 1rem`)
|
||||
- White/card-colored background on gradient pages
|
||||
- Subtle shadow (`box-shadow: 0 2px 8px rgba(0,0,0,0.1)`)
|
||||
- Bold label (font-weight 700), regular-weight input text
|
||||
|
||||
### Buttons
|
||||
|
||||
- Rounded corners matching card fields (`border-radius: 14px`)
|
||||
- Accent color background with dark text
|
||||
- Bold/ExtraBold weight (700–800)
|
||||
- Subtle shadow for depth
|
||||
|
||||
### Layout
|
||||
|
||||
- Mobile: full-width content with horizontal padding (~1.2rem)
|
||||
- Desktop: centered column, max-width ~480px, gradient background fills viewport
|
||||
- Vertical spacing between elements: ~0.75rem (compact), ~1.2rem (sections)
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
## Status
|
||||
|
||||
- Total stories: 20
|
||||
- Total stories: 21
|
||||
- Complete: 0
|
||||
- Remaining: 20
|
||||
- Remaining: 21
|
||||
|
||||
## Token Model
|
||||
|
||||
@@ -467,3 +467,20 @@ The following terms are used consistently across all stories:
|
||||
|
||||
**Notes:** The overseer identified that using the expiry date as a deletion mechanism (setting it to today or a past date in US-5) was unintuitive and conflated two different actions. US-5 now enforces that the expiry date can only be set to a future date. If the organizer wants the event gone immediately, they use this explicit deletion feature. Unlike cancellation (US-18), which keeps the event visible with a cancellation notice until the expiry date, deletion removes the event entirely and immediately. This is the organizer's "nuclear option" — useful when the event was created by mistake, contains wrong information, or is no longer needed at all. The deletion behavior is identical to what US-12 does automatically after expiry, but triggered manually and immediately by the organizer.
|
||||
|
||||
---
|
||||
|
||||
### US-20: 404 page
|
||||
|
||||
**As a** user who navigates to a non-existent URL,
|
||||
**I want to** see a helpful error page,
|
||||
**so that** I can find my way back instead of seeing a blank screen.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Unknown routes show a "Page not found" message
|
||||
- [ ] The page includes a link back to the home page
|
||||
- [ ] The page follows the design system
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Notes:** Identified during US-1 post-review: navigating to an unknown path currently shows a blank page because the Vue Router has no catch-all route. This is a small UX story but important for polish. Note: This story has no structural dependencies but requires the frontend scaffold from T-4 (which includes T-1) to be practically implementable.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user