Compare commits
16 Commits
0c41f41753
...
0a83abbea3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a83abbea3 | ||
| ffea279b54 | |||
| 4cfac860aa | |||
| e3ca613210 | |||
| 14f11875a4 | |||
| a029e951b8 | |||
| 84feeb9997 | |||
| f3d4b5fa17 | |||
| c80074093c | |||
| 830ca55f20 | |||
| eeadaf58c7 | |||
| 387445e089 | |||
| c2bbb78b7b | |||
| 91e566efea | |||
| b8421274b4 | |||
| 747ed18945 |
22
.claude/hooks/openapi-validate.sh
Executable file
22
.claude/hooks/openapi-validate.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Read hook input from stdin (JSON with tool_input.file_path)
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")
|
||||
|
||||
# Only run for OpenAPI spec files
|
||||
case "$FILE_PATH" in
|
||||
*/openapi/*.yaml|*/openapi/*.yml) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
cd "$CLAUDE_PROJECT_DIR/backend"
|
||||
|
||||
# Run validation (zero-config: structural validity only)
|
||||
if OUTPUT=$(npx @redocly/cli@latest lint src/main/resources/openapi/api.yaml --format=stylish 2>&1); then
|
||||
echo '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"✓ OpenAPI spec validation passed."}}'
|
||||
else
|
||||
ESCAPED=$(echo "$OUTPUT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":$ESCAPED}}"
|
||||
fi
|
||||
@@ -13,6 +13,11 @@
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/frontend-check.sh\"",
|
||||
"timeout": 120
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/openapi-validate.sh\"",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ These are the non-negotiable principles of this project. Every decision — arch
|
||||
### Methodology
|
||||
|
||||
- Follow Research → Spec → Test → Implement → Review. No shortcuts.
|
||||
- API-first development: the OpenAPI spec (`backend/src/main/resources/openapi/api.yaml`) is the single source of truth for the REST API contract. Define endpoints and schemas in the spec first, then generate backend interfaces and frontend types before writing any implementation code.
|
||||
- Never write implementation code without a specification.
|
||||
- Always write tests before implementation (TDD). Red → Green → Refactor.
|
||||
- Refactoring is permitted freely as long as it does not alter the fundamental architecture.
|
||||
@@ -31,6 +32,11 @@ These are the non-negotiable principles of this project. Every decision — arch
|
||||
- For every feature, critically evaluate what data is necessary. Only store what is absolutely required for functionality.
|
||||
- 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.
|
||||
|
||||
10
README.md
10
README.md
@@ -150,6 +150,16 @@ ArchUnit enforces hexagonal boundaries: domain must not depend on adapters, appl
|
||||
|---------------------|------------------|-------------------|
|
||||
| Prettier | `npm run format` | Formatting issues |
|
||||
|
||||
### OpenAPI Spec (YAML)
|
||||
|
||||
**After editing an `openapi/*.yaml` file** (PostToolUse hook):
|
||||
|
||||
| What | Command | Fails on |
|
||||
|---------------------|--------------------------|-----------------------------------|
|
||||
| Redocly CLI | `redocly lint api.yaml` | Structural and ruleset violations |
|
||||
|
||||
Validates the OpenAPI 3.1 spec against the Redocly `recommended` ruleset (with `security-defined` disabled, since endpoints are intentionally public). Runs via `npx @redocly/cli@latest`.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Compose
|
||||
|
||||
5
backend/redocly.yaml
Normal file
5
backend/redocly.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
extends:
|
||||
- recommended
|
||||
|
||||
rules:
|
||||
security-defined: off
|
||||
@@ -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
|
||||
responses:
|
||||
"200":
|
||||
description: Service is healthy
|
||||
- events
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HealthResponse"
|
||||
$ref: "#/components/schemas/CreateEventRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Event created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateEventResponse"
|
||||
"400":
|
||||
description: Validation failed
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ValidationProblemDetail"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
HealthResponse:
|
||||
CreateEventRequest:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- title
|
||||
- dateTime
|
||||
- expiryDate
|
||||
properties:
|
||||
status:
|
||||
title:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
description:
|
||||
type: string
|
||||
maxLength: 2000
|
||||
dateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Event date and time with UTC offset (ISO 8601)
|
||||
example: "2026-03-15T20:00:00+01:00"
|
||||
location:
|
||||
type: string
|
||||
maxLength: 500
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
description: Date after which event data is deleted. Must be in the future.
|
||||
example: "2026-06-15"
|
||||
|
||||
CreateEventResponse:
|
||||
type: object
|
||||
required:
|
||||
- eventToken
|
||||
- organizerToken
|
||||
- title
|
||||
- dateTime
|
||||
- expiryDate
|
||||
properties:
|
||||
eventToken:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Public token for the event URL
|
||||
organizerToken:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Secret token for organizer access
|
||||
title:
|
||||
type: string
|
||||
dateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
expiryDate:
|
||||
type: string
|
||||
format: date
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
format: uri
|
||||
default: "about:blank"
|
||||
title:
|
||||
type: string
|
||||
status:
|
||||
type: integer
|
||||
detail:
|
||||
type: string
|
||||
instance:
|
||||
type: string
|
||||
format: uri
|
||||
additionalProperties: true
|
||||
|
||||
ValidationProblemDetail:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ProblemDetail"
|
||||
- type: object
|
||||
properties:
|
||||
fieldErrors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- field
|
||||
- message
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
example: UP
|
||||
|
||||
@@ -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/)
|
||||
215
docs/agents/research/2026-03-04-openapi-validation-pipeline.md
Normal file
215
docs/agents/research/2026-03-04-openapi-validation-pipeline.md
Normal file
@@ -0,0 +1,215 @@
|
||||
---
|
||||
date: "2026-03-04T22:27:37.933286+00:00"
|
||||
git_commit: 91e566efea0cbf53ba06a29b63317b7435609bd8
|
||||
branch: master
|
||||
topic: "Automatic OpenAPI Validation Pipelines for Backpressure Hooks"
|
||||
tags: [research, openapi, validation, hooks, backpressure, linting]
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Research: Automatic OpenAPI Validation Pipelines
|
||||
|
||||
## Research Question
|
||||
|
||||
What automatic validation pipelines exist for OpenAPI specs that can be integrated into the current Claude Code backpressure hook setup, running after the OpenAPI spec has been modified?
|
||||
|
||||
## Summary
|
||||
|
||||
The project already has a PostToolUse hook system that runs backend compile checks and frontend lint/type-checks after Edit/Write operations. Adding OpenAPI spec validation requires a new hook script that triggers specifically when `api.yaml` is modified. Several CLI tools support OpenAPI 3.1.0 validation — **Redocly CLI** is the strongest fit given the existing Node.js toolchain, MIT license, active maintenance, and zero-config baseline.
|
||||
|
||||
## Current Backpressure Setup
|
||||
|
||||
### Hook Architecture (`.claude/settings.json`)
|
||||
|
||||
The project uses Claude Code hooks for automated quality gates:
|
||||
|
||||
| Hook Event | Trigger | Scripts |
|
||||
|---|---|---|
|
||||
| `PostToolUse` | `Edit\|Write` tool calls | `backend-compile-check.sh`, `frontend-check.sh` |
|
||||
| `Stop` | Agent attempts to stop | `run-tests.sh` |
|
||||
|
||||
### How Hooks Work
|
||||
|
||||
Each hook script:
|
||||
1. Reads JSON from stdin containing `tool_input.file_path`
|
||||
2. Pattern-matches the file path to decide if it should run
|
||||
3. Executes validation (compile, lint, type-check, test)
|
||||
4. Returns JSON with either success message or failure details
|
||||
5. On failure: outputs `hookSpecificOutput` with error context (PostToolUse) or `{"decision":"block"}` (Stop)
|
||||
|
||||
### Existing Pattern for File Matching
|
||||
|
||||
```bash
|
||||
# backend-compile-check.sh — matches Java files
|
||||
case "$FILE_PATH" in
|
||||
*/backend/src/*.java|backend/src/*.java) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# frontend-check.sh — matches TS/Vue files
|
||||
case "$FILE_PATH" in
|
||||
*/frontend/src/*.ts|*/frontend/src/*.vue|frontend/src/*.ts|frontend/src/*.vue) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
An OpenAPI validation hook would use the same pattern:
|
||||
```bash
|
||||
case "$FILE_PATH" in
|
||||
*/openapi/api.yaml|*/openapi/*.yaml) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
### Existing OpenAPI Tooling in the Project
|
||||
|
||||
- **Backend:** `openapi-generator-maven-plugin` v7.20.0 generates Spring interfaces from `api.yaml` (`pom.xml:149-178`)
|
||||
- **Frontend:** `openapi-typescript` v7.13.0 generates TypeScript types; `openapi-fetch` v0.17.0 provides type-safe client
|
||||
- **No validation/linting tools** currently installed — no Redocly, Spectral, or other linter config exists
|
||||
|
||||
## Tool Evaluation
|
||||
|
||||
### Redocly CLI (`@redocly/cli`)
|
||||
|
||||
| Attribute | Value |
|
||||
|---|---|
|
||||
| OpenAPI 3.1 | Full support |
|
||||
| Install | `npm install -g @redocly/cli` or `npx @redocly/cli@latest` |
|
||||
| CLI | `redocly lint api.yaml` |
|
||||
| License | MIT |
|
||||
| Maintenance | Very active — latest v2.20.3 (2026-03-03), daily/weekly releases |
|
||||
| GitHub | ~1.4k stars (Redocly ecosystem: 24k+ combined) |
|
||||
|
||||
**Checks:** Structural validity against OAS schema, configurable linting rules (naming, descriptions, operation IDs, security), style/consistency enforcement. Built-in rulesets: `minimal`, `recommended`, `recommended-strict`. Zero-config baseline works immediately. Custom rules via `redocly.yaml`.
|
||||
|
||||
**Fit for this project:** Node.js already in the toolchain (frontend). `npx` form requires no permanent install. MIT license compatible with GPL-3.0. The `@redocly/openapi-core` package is already present as a transitive dependency of `openapi-typescript` in `node_modules`.
|
||||
|
||||
### Spectral (`@stoplight/spectral-cli`)
|
||||
|
||||
| Attribute | Value |
|
||||
|---|---|
|
||||
| OpenAPI 3.1 | Full support (since v6.x) |
|
||||
| Install | `npm install -g @stoplight/spectral-cli` |
|
||||
| CLI | `spectral lint api.yaml` |
|
||||
| License | Apache 2.0 |
|
||||
| Maintenance | Active — latest v6.15.0 (2025-04-22), slower cadence |
|
||||
| GitHub | ~3k stars |
|
||||
|
||||
**Checks:** Schema compliance, missing descriptions/tags/operationIds, contact/license metadata. Highly extensible custom rulesets via YAML/JS. Configurable severity levels.
|
||||
|
||||
**Fit for this project:** Well-established industry standard. Apache 2.0 compatible with GPL. Less actively maintained than Redocly (10 months since last release). Heavier custom ruleset system may be over-engineered for current needs.
|
||||
|
||||
### Vacuum (`daveshanley/vacuum`)
|
||||
|
||||
| Attribute | Value |
|
||||
|---|---|
|
||||
| OpenAPI 3.1 | Full support (via libopenapi) |
|
||||
| Install | `brew install daveshanley/vacuum/vacuum` or Go binary |
|
||||
| CLI | `vacuum lint api.yaml` |
|
||||
| License | MIT |
|
||||
| Maintenance | Active — latest release 2025-12-22 |
|
||||
| GitHub | ~1k stars |
|
||||
|
||||
**Checks:** Structural validation, Spectral-compatible rulesets, OWASP security checks, naming conventions, descriptions/examples/tags. Single Go binary — no runtime dependencies.
|
||||
|
||||
**Fit for this project:** Zero-dependency binary is appealing for CI. However, adds a non-Node.js tool dependency when the project already has Node.js. Spectral ruleset compatibility is a plus for portability.
|
||||
|
||||
### oasdiff (`oasdiff/oasdiff`)
|
||||
|
||||
| Attribute | Value |
|
||||
|---|---|
|
||||
| OpenAPI 3.1 | Beta |
|
||||
| Install | `brew install oasdiff` or Go binary |
|
||||
| CLI | `oasdiff breaking base.yaml revision.yaml` |
|
||||
| License | Apache 2.0 |
|
||||
| Maintenance | Active — latest v1.11.10 (2026-02-05) |
|
||||
| GitHub | ~1.1k stars |
|
||||
|
||||
**Checks:** 300+ breaking change detection rules (paths, parameters, schemas, security, headers, enums). Requires two spec versions to compare — not a standalone validator.
|
||||
|
||||
**Fit for this project:** Different category — detects breaking changes between spec versions, not structural validity. Useful as a CI-only check comparing `HEAD~1` vs `HEAD`. OAS 3.1 support is still beta.
|
||||
|
||||
### Not Recommended
|
||||
|
||||
- **swagger-cli:** Abandoned, no OAS 3.1 support
|
||||
- **IBM OpenAPI Validator:** Active but opinionated IBM-specific rules add configuration overhead for no benefit
|
||||
|
||||
## Tool Comparison Matrix
|
||||
|
||||
| Tool | OAS 3.1 | License | Last Release | Stars | Runtime | Category |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Redocly CLI** | Full | MIT | 2026-03-03 | ~1.4k | Node.js | Lint + validate |
|
||||
| **Spectral** | Full | Apache 2.0 | 2025-04-22 | ~3k | Node.js | Lint |
|
||||
| **Vacuum** | Full | MIT | 2025-12-22 | ~1k | Go binary | Lint + validate |
|
||||
| **oasdiff** | Beta | Apache 2.0 | 2026-02-05 | ~1.1k | Go binary | Breaking changes |
|
||||
|
||||
## Integration Pattern
|
||||
|
||||
### Hook Script Structure
|
||||
|
||||
An OpenAPI validation hook would follow the existing pattern in `.claude/hooks/`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" 2>/dev/null || echo "")
|
||||
|
||||
# Only run for OpenAPI spec files
|
||||
case "$FILE_PATH" in
|
||||
*/openapi/*.yaml|*/openapi/*.yml) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
cd "$CLAUDE_PROJECT_DIR/backend"
|
||||
|
||||
# Run validation
|
||||
if OUTPUT=$(npx @redocly/cli@latest lint src/main/resources/openapi/api.yaml --format=stylish 2>&1); then
|
||||
echo '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"✓ OpenAPI spec validation passed."}}'
|
||||
else
|
||||
ESCAPED=$(echo "$OUTPUT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":$ESCAPED}}"
|
||||
fi
|
||||
```
|
||||
|
||||
### Registration in `.claude/settings.json`
|
||||
|
||||
The hook would be added to the existing `PostToolUse` array alongside the compile and lint hooks:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/openapi-validate.sh\"",
|
||||
"timeout": 120
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration (Optional)
|
||||
|
||||
A `redocly.yaml` in the project root or `backend/` directory can customize rules:
|
||||
|
||||
```yaml
|
||||
extends:
|
||||
- recommended
|
||||
|
||||
rules:
|
||||
operation-operationId: error
|
||||
tag-description: warn
|
||||
no-ambiguous-paths: error
|
||||
```
|
||||
|
||||
## Code References
|
||||
|
||||
- `.claude/settings.json:1-32` — Hook configuration (PostToolUse + Stop events)
|
||||
- `.claude/hooks/backend-compile-check.sh` — Java file detection pattern + compile check
|
||||
- `.claude/hooks/frontend-check.sh` — TS/Vue file detection pattern + type-check + lint
|
||||
- `.claude/hooks/run-tests.sh` — Stop hook with test execution and block/approve logic
|
||||
- `backend/pom.xml:149-178` — openapi-generator-maven-plugin configuration
|
||||
- `backend/src/main/resources/openapi/api.yaml` — The OpenAPI 3.1.0 spec to validate
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should the validation use a pinned version (`npx @redocly/cli@1.x.x`) or latest? Pinned is more reproducible; latest gets rule updates automatically.
|
||||
- Should a `redocly.yaml` config be added immediately with the `recommended` ruleset, or start with zero-config (structural validation only) and add rules incrementally?
|
||||
- Is breaking change detection (oasdiff) desirable as a separate CI check, or is structural validation sufficient for now?
|
||||
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>
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.50.0",
|
||||
"eslint-plugin-oxlint": "~1.51.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^28.1.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"oxlint": "~1.50.0",
|
||||
"oxlint": "~1.51.0",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
|
||||
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>
|
||||
|
||||
<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';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
@font-face {
|
||||
font-family: 'Sora';
|
||||
src: url('@/assets/fonts/Sora-Variable.woff2') format('woff2');
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
: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;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
*,
|
||||
*::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;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
background: var(--gradient-primary);
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
min-height: 100vh;
|
||||
padding: var(--content-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Card-style form fields */
|
||||
.form-field {
|
||||
background: var(--color-card);
|
||||
border: none;
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--spacing-md) var(--spacing-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.form-field:focus {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.form-field::placeholder {
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
textarea.form-field {
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
/* Form group (label + input) */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Primary action button */
|
||||
.btn-primary {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-button);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-button);
|
||||
transition: opacity 0.2s ease, transform 0.1s ease;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.field-error {
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ US-1 hat 8 ACs, aber implizit beinhaltet sie den gesamten Erstaufbau des Stacks:
|
||||
| Schicht | Was US-1 implizit verlangt |
|
||||
|---------|---------------------------|
|
||||
| DB | Event-Tabelle, Migration, Flyway-Setup |
|
||||
| Backend | Entity, Repository, Service, REST Controller, UUID-Generation, Honeypot-Validation, JSON-Serialisierung |
|
||||
| Backend | Entity, Repository, Service, REST Controller, UUID-Generation, JSON-Serialisierung |
|
||||
| Frontend | Formular, Validierung, API-Call, localStorage-Management, Routing zur Erstellungsseite, Redirect zur Event-Page |
|
||||
| Integration | Frontend↔Backend Verbindung, CORS-Konfiguration |
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,160 +1,123 @@
|
||||
# Implementation Phases
|
||||
# Implementation Order
|
||||
|
||||
A recommended implementation order based on the dependency graph across all stories and setup tasks.
|
||||
Sequential implementation order for all user stories. No parallelization — one story at a time.
|
||||
|
||||
## Phase 0: Project Infrastructure
|
||||
## Progress Tracker
|
||||
|
||||
All setup tasks must complete before any user story work begins. T-3 can run in parallel with Phase 1.
|
||||
- [ ] US-1 Create event
|
||||
- [ ] US-2 View event page
|
||||
- [ ] US-3 RSVP
|
||||
- [ ] US-5 Edit event
|
||||
- [ ] US-4 Manage guest list
|
||||
- [ ] US-18 Cancel event
|
||||
- [ ] US-19 Delete event
|
||||
- [ ] US-12 Auto-cleanup after expiry
|
||||
- [ ] US-13 Limit active events
|
||||
- [ ] US-6 Bookmark event
|
||||
- [ ] US-7 Local event overview
|
||||
- [ ] US-17 Dark/light mode
|
||||
- [ ] US-8 Calendar integration
|
||||
- [ ] US-11 QR code
|
||||
- [ ] US-9 Change highlights
|
||||
- [ ] US-10a Update messages
|
||||
- [ ] US-10b New-update indicator
|
||||
- [ ] US-15 Color themes
|
||||
- [ ] US-16 Unsplash header images
|
||||
- [ ] US-14 PWA install
|
||||
|
||||
| Order | Task | Depends on | Notes |
|
||||
|-------|------|------------|-------|
|
||||
| 1 | T-1: Initialize monorepo structure | — | Scaffolds empty backend + frontend projects |
|
||||
| 2 | T-5: API-first tooling setup | T-1 | OpenAPI spec, codegen plugins, generated types |
|
||||
| 3 | T-2: Docker deployment setup | T-1, T-5 | Multi-stage Dockerfile — builds backend + frontend into one container |
|
||||
| 4 | T-4: Development infrastructure | T-2, T-5 | Migrations, DB wiring, router, test infra, docker-compose docs — gates all user stories |
|
||||
| 4* | T-3: CI/CD pipeline | T-1, T-2 | Parallelizable with T-4. Uses Gitea Actions (per Q-5 resolution) |
|
||||
## Prerequisites
|
||||
|
||||
## Phase 1: Core Event Flow (Vertical Slice)
|
||||
All setup tasks (T-1 through T-5) are complete.
|
||||
|
||||
The end-to-end journey from creating an event to viewing it to RSVPing. US-1 is the "Durchstich" that bootstraps the full stack (DB table, backend endpoint, frontend form, localStorage, routing). It will take significantly longer than subsequent stories.
|
||||
## Order Rationale
|
||||
|
||||
| Order | Story | Depends on | Parallelizable |
|
||||
|-------|-------|------------|----------------|
|
||||
| 1 | US-1: Create an event | T-4 | — |
|
||||
| 2 | US-2: View event landing page | US-1 | — |
|
||||
| 3 | US-3: RSVP to an event | US-2 | — |
|
||||
### Increment 1: Minimal Viable Event — US-1, US-2, US-3
|
||||
|
||||
These three stories are strictly sequential. No parallelization within this phase.
|
||||
The vertical slice. After these three stories, the app is usable: an organizer creates an event, shares the link, guests view it and RSVP.
|
||||
|
||||
## Phase 2: Organizer Management & Event Lifecycle
|
||||
| # | Story | Depends on | Delivers |
|
||||
|---|-------|------------|----------|
|
||||
| 1 | US-1: Create event | T-4 | Event creation with tokens, localStorage |
|
||||
| 2 | US-2: View event page | US-1 | Public event page with attendee list, expired state |
|
||||
| 3 | US-3: RSVP | US-2 | Attend/decline flow, localStorage dedup |
|
||||
|
||||
All stories in this phase depend on US-1 (and T-4 transitively). They can be implemented in parallel since they are independent of each other. Some are more useful after Phase 1 completes (e.g. US-4 needs RSVPs to manage), but they are structurally implementable after US-1.
|
||||
### Increment 2: Organizer Toolset — US-5, US-4
|
||||
|
||||
| Story | Depends on | Notes |
|
||||
|-------|------------|-------|
|
||||
| US-4: Manage guest list | US-1 | Most useful after US-3 provides RSVPs to manage |
|
||||
| US-5: Edit event details | US-1 | Required by US-9 in Phase 3 |
|
||||
| US-18: Cancel an event | US-1 | Enables deferred ACs in US-2, US-3, US-8 |
|
||||
| US-19: Delete an event | US-1 | Enables deferred AC in US-2 |
|
||||
| US-12: Automatic data deletion after expiry | US-1 | Enables deferred AC in US-2; server-side scheduled job |
|
||||
| US-13: Limit active events | US-1 | Server-side config; independent of all other stories |
|
||||
The organizer needs to correct mistakes and moderate spam before the app goes to real users.
|
||||
|
||||
**Recommended order within phase:** US-5, US-4, US-18, US-19, US-12, US-13 — starting with US-5 because US-9 (Phase 3) depends on it, and US-4 because it completes the organizer toolset around RSVPs.
|
||||
| # | Story | Depends on | Delivers |
|
||||
|---|-------|------------|----------|
|
||||
| 4 | US-5: Edit event | US-1 | Edit all fields, expiry-must-be-future constraint |
|
||||
| 5 | US-4: Manage guest list | US-1 | View RSVPs, delete spam entries |
|
||||
|
||||
## Phase 3: Enhanced Event Page Features
|
||||
US-5 before US-4: US-9 (change highlights) depends on US-5, so getting it done early unblocks Phase 3 work.
|
||||
|
||||
Features that enrich the event page for guests. Most depend on US-2 (event page exists). US-9 additionally requires US-5 (editing). US-10b requires US-10a.
|
||||
### Increment 3: Event Lifecycle — US-18, US-19, US-12, US-13
|
||||
|
||||
| Story | Depends on | Parallelizable with |
|
||||
|-------|------------|---------------------|
|
||||
| US-6: Bookmark an event | US-2 | US-8, US-10a, US-11 |
|
||||
| US-8: Add event to calendar (.ics / webcal) | US-2 | US-6, US-10a, US-11 |
|
||||
| US-9: Highlight changed event details | US-2, US-5 | US-6, US-8, US-10a, US-11 (if US-5 is done) |
|
||||
| US-10a: Post update messages | US-1, US-2 | US-6, US-8, US-11 |
|
||||
| US-10b: New-update indicator | US-10a | Must follow US-10a |
|
||||
| US-11: Generate QR code | US-2 | US-6, US-8, US-10a |
|
||||
Complete lifecycle management. After this increment, the privacy guarantee is enforced and abuse prevention is in place.
|
||||
|
||||
**Recommended order within phase:** US-6, US-8, US-11 (simple, independent), then US-10a → US-10b (sequential pair), then US-9 (requires US-5 from Phase 2).
|
||||
| # | Story | Depends on | Delivers | Activates deferred ACs |
|
||||
|---|-------|------------|----------|----------------------|
|
||||
| 6 | US-18: Cancel event | US-1 | One-way cancellation with optional message, expiry adjustment | US-2 AC5, US-3 AC11 |
|
||||
| 7 | US-19: Delete event | US-1 | Immediate permanent deletion, localStorage cleanup | US-2 AC6 (partial) |
|
||||
| 8 | US-12: Auto-cleanup | US-1 | Scheduled deletion after expiry, silent logging | US-2 AC6 (complete) |
|
||||
| 9 | US-13: Event limit | US-1 | `MAX_ACTIVE_EVENTS` env var, server-side enforcement | — |
|
||||
|
||||
## Phase 4: Visual Customization
|
||||
When implementing US-18, US-19, and US-12: immediately activate their deferred ACs in US-2 and US-3 (cancelled state display, RSVP blocking, event-not-found handling). These stories exist at this point — no reason to defer further.
|
||||
|
||||
Event-level theming and image selection. Both depend on US-1 and US-2. US-15 and US-16 are independent of each other but share the event creation/editing form surface area, so coordinating them is beneficial.
|
||||
### Increment 4: App Shell — US-6, US-7, US-17
|
||||
|
||||
| Story | Depends on | Notes |
|
||||
|-------|------------|-------|
|
||||
| US-15: Choose event color theme | US-1, US-2 | Predefined theme picker in creation/edit forms |
|
||||
| US-16: Select header image from Unsplash | US-1, US-2 | Optional feature gated by API key config |
|
||||
The app gets a home screen. Users can find their events without the original link.
|
||||
|
||||
**Recommended order:** US-15 first (simpler, no external dependency), then US-16. Consider the interaction between event themes and dark/light mode (US-17) — implement US-17 before or alongside US-15 if possible.
|
||||
| # | Story | Depends on | Delivers |
|
||||
|---|-------|------------|----------|
|
||||
| 10 | US-6: Bookmark event | US-2 | Client-only bookmark, no server contact |
|
||||
| 11 | US-7: Local event overview | — | Root page `/` with all tracked events from localStorage |
|
||||
| 12 | US-17: Dark/light mode | — | System preference detection, manual toggle, localStorage persistence |
|
||||
|
||||
## Phase 5: App Shell & PWA
|
||||
US-6 before US-7: bookmarking populates localStorage entries that the overview displays. Without US-6, the overview only shows created and RSVPed events.
|
||||
|
||||
Client-side infrastructure and app-level UX features. These have no or minimal structural dependencies but are only meaningfully testable after earlier phases provide content and data.
|
||||
US-17 here (not in a late phase): event color themes (US-15) must account for dark/light mode. Having it in place before US-15 avoids rework.
|
||||
|
||||
| Story | Depends on | Practically useful after |
|
||||
|-------|------------|------------------------|
|
||||
| US-7: Local event overview | None (structural) | US-1, US-3, US-6 populate localStorage |
|
||||
| US-14: Install as PWA | T-4 (structural) | US-2, US-7 provide pages to cache |
|
||||
| US-17: Dark/light mode | None (structural) | T-4 provides frontend scaffold |
|
||||
### Increment 5: Rich Event Page — US-8, US-11, US-9, US-10a, US-10b
|
||||
|
||||
**Recommended order:** US-17 (can be started early once the frontend scaffold exists — consider implementing alongside Phase 2 or 3), then US-7 (after localStorage-populating stories are available), then US-14 (after the app has real pages and assets).
|
||||
Features that enrich the event page for guests and organizers.
|
||||
|
||||
**Note on US-17 timing:** US-17 is listed in Phase 5 for logical grouping, but it can be implemented as early as Phase 2 since it only needs the frontend scaffold. Implementing it earlier is recommended because US-15 (Phase 4) must consider the interaction between event color themes and dark/light mode. Having dark/light mode in place before US-15 simplifies that work.
|
||||
| # | Story | Depends on | Delivers |
|
||||
|---|-------|------------|----------|
|
||||
| 13 | US-8: Calendar .ics + webcal | US-2 | RFC 5545 download, webcal subscription, STATUS:CANCELLED support |
|
||||
| 14 | US-11: QR code | US-2 | Server-generated QR, SVG/PNG download |
|
||||
| 15 | US-9: Change highlights | US-2, US-5 | Field-level change indicators, localStorage-based read tracking |
|
||||
| 16 | US-10a: Update messages | US-1, US-2 | Organizer posts, reverse-chronological display, delete capability |
|
||||
| 17 | US-10b: New-update indicator | US-10a | localStorage-based unread badge |
|
||||
|
||||
## Deferred Acceptance Criteria
|
||||
US-8 benefits from US-18 being complete: `STATUS:CANCELLED` in .ics can be implemented directly instead of deferred.
|
||||
|
||||
Several stories contain ACs that reference features from later phases. These are marked `[deferred until US-X is implemented]` in the story text:
|
||||
US-9 benefits from US-5 being complete (increment 2): no dependency waiting.
|
||||
|
||||
| Story | AC | Deferred until | Phase unlocked |
|
||||
|-------|-----|---------------|----------------|
|
||||
| US-2 AC 5 | Cancelled state display | US-18 | Phase 2 |
|
||||
| US-2 AC 6 | Event not found (expiry deletion) | US-12 | Phase 2 |
|
||||
| US-2 AC 6 | Event not found (organizer deletion) | US-19 | Phase 2 |
|
||||
| US-3 AC 11 | RSVP blocked on cancelled event | US-18 | Phase 2 |
|
||||
| US-8 AC 9 | STATUS:CANCELLED in .ics | US-18 | Phase 2 |
|
||||
| US-12 AC 2 | Delete stored header images | US-16 | Phase 4 |
|
||||
### Increment 6: Visual Polish & PWA — US-15, US-16, US-14
|
||||
|
||||
Once the referenced story is implemented, revisit the deferring story to activate the deferred AC.
|
||||
Final layer: visual customization and native app feel.
|
||||
|
||||
## Dependency Graph
|
||||
| # | Story | Depends on | Delivers |
|
||||
|---|-------|------------|----------|
|
||||
| 18 | US-15: Color themes | US-1, US-2 | Predefined theme picker, event-scoped styling |
|
||||
| 19 | US-16: Unsplash images | US-1, US-2 | Server-proxied search, local storage, attribution |
|
||||
| 20 | US-14: PWA | T-4 | Manifest, service worker, installability |
|
||||
|
||||
Render this diagram at [mermaid.live](https://mermaid.live) or view it directly in Gitea (which renders `mermaid` blocks natively).
|
||||
US-15 before US-16: themes are self-contained, Unsplash adds external API complexity.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
classDef infra fill:#4a90d9,stroke:#2c5f8a,color:#fff
|
||||
classDef core fill:#e8a838,stroke:#b07c1e,color:#fff
|
||||
classDef organizer fill:#50b86c,stroke:#2d8043,color:#fff
|
||||
classDef enhanced fill:#9b59b6,stroke:#6c3483,color:#fff
|
||||
classDef visual fill:#e74c3c,stroke:#a93226,color:#fff
|
||||
classDef shell fill:#7f8c8d,stroke:#566566,color:#fff
|
||||
US-14 last: PWA caching is most effective when the app has all its pages and assets. Service worker strategy can cover everything in one pass.
|
||||
|
||||
%% Phase 0: Infrastructure
|
||||
T1(["T-1: Monorepo"]):::infra --> T5(["T-5: API-First Tooling"]):::infra
|
||||
T1 --> T2(["T-2: Docker"]):::infra
|
||||
T5 --> T2
|
||||
T2 --> T4(["T-4: Dev Infra + DB"]):::infra
|
||||
T5 --> T4
|
||||
T2 --> T3(["T-3: CI/CD"]):::infra
|
||||
Note: US-12 AC2 (delete stored header images on expiry) remains deferred until US-16 is implemented. When implementing US-16, activate this AC in US-12.
|
||||
|
||||
%% Phase 1: Core Event Flow
|
||||
T4 --> US1["US-1: Create Event"]:::core
|
||||
US1 --> US2["US-2: View Event"]:::core
|
||||
US2 --> US3["US-3: RSVP"]:::core
|
||||
## Deferred AC Activation Schedule
|
||||
|
||||
%% Phase 2: Organizer & Lifecycle (branch from US-1)
|
||||
US1 --> US4["US-4: Guest List"]:::organizer
|
||||
US1 --> US5["US-5: Edit Event"]:::organizer
|
||||
US1 --> US18["US-18: Cancel"]:::organizer
|
||||
US1 --> US19["US-19: Delete"]:::organizer
|
||||
US1 --> US12["US-12: Auto-Cleanup"]:::organizer
|
||||
US1 --> US13["US-13: Event Limit"]:::organizer
|
||||
|
||||
%% Phase 3: Enhanced Features (branch from US-2)
|
||||
US2 --> US6["US-6: Bookmark"]:::enhanced
|
||||
US2 --> US8["US-8: Calendar .ics"]:::enhanced
|
||||
US2 --> US10a["US-10a: Messages"]:::enhanced
|
||||
US2 --> US11["US-11: QR Code"]:::enhanced
|
||||
US5 --> US9["US-9: Change Highlights"]:::enhanced
|
||||
US2 --> US9
|
||||
US10a --> US10b["US-10b: New-Update Badge"]:::enhanced
|
||||
|
||||
%% Phase 4: Visual Customization (branch from US-2)
|
||||
US2 --> US15["US-15: Color Themes"]:::visual
|
||||
US2 --> US16["US-16: Unsplash Images"]:::visual
|
||||
|
||||
%% Phase 5: App Shell & PWA
|
||||
T4 --> US14["US-14: PWA"]:::shell
|
||||
US7["US-7: Local Overview"]:::shell
|
||||
US17["US-17: Dark/Light Mode"]:::shell
|
||||
```
|
||||
|
||||
**Legend:**
|
||||
- 🔵 Infrastructure (T-1 – T-5)
|
||||
- 🟠 Core Event Flow (US-1 – US-3)
|
||||
- 🟢 Organizer & Lifecycle (US-4, US-5, US-12, US-13, US-18, US-19)
|
||||
- 🟣 Enhanced Features (US-6, US-8 – US-11)
|
||||
- 🔴 Visual Customization (US-15, US-16)
|
||||
- ⚪ App Shell & PWA (US-7, US-14, US-17)
|
||||
|
||||
US-7 and US-17 appear as isolated nodes — they have no structural dependencies but are only practically useful after earlier phases provide content (see Phase 5 notes above).
|
||||
| When implementing | Activate deferred AC in | AC description |
|
||||
|-------------------|------------------------|----------------|
|
||||
| US-18 (#6) | US-2 AC5 | Cancelled state display |
|
||||
| US-18 (#6) | US-3 AC11 | RSVP blocked on cancelled event |
|
||||
| US-18 (#6) | US-8 AC9 | STATUS:CANCELLED in .ics (if US-8 not yet done — in this order, US-8 comes later, so implement directly) |
|
||||
| US-19 (#7) | US-2 AC6 | Event not found (organizer deletion) |
|
||||
| US-12 (#8) | US-2 AC6 | Event not found (expiry deletion) |
|
||||
| US-16 (#19) | US-12 AC2 | Delete stored header images on expiry |
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
## Status
|
||||
|
||||
- Total stories: 20
|
||||
- Total stories: 21
|
||||
- Complete: 0
|
||||
- Remaining: 20
|
||||
- Remaining: 21
|
||||
|
||||
## Token Model
|
||||
|
||||
@@ -32,13 +32,14 @@ The following terms are used consistently across all stories:
|
||||
- [ ] The event token, title, and date are also stored in localStorage alongside the organizer token, so the local event overview (US-7) can display the event without additional server contact
|
||||
- [ ] No account, login, or personal data is required to create an event
|
||||
- [ ] The expiry date field is mandatory and cannot be left blank
|
||||
- [ ] A honeypot field is present in the event creation form: hidden from real users; any submission with the field populated is silently discarded server-side
|
||||
- [ ] The event is not discoverable except via its direct link
|
||||
|
||||
**Dependencies:** T-4
|
||||
|
||||
**Notes:** Non-guessable tokens (UUIDs) are specified in Ideen.md under security. Expiry date is mandatory per Ideen.md. No registration required per core principles. Per Q-4 resolution: organizer authentication uses the organizer token stored in localStorage on the device where the event was created. The organizer token is separate from the event token — since the event link is designed to be shared in group chats, using the same token for both public access and organizer auth would allow any guest to manage the event.
|
||||
|
||||
**Addendum (2026-03-04):** Honeypot field removed — overengineered for this project's scope. Expiry date must be in the future at creation time — an event should never exist in an invalid state (resolved during US-1 research).
|
||||
|
||||
---
|
||||
|
||||
### US-2: View event landing page
|
||||
@@ -78,14 +79,15 @@ The following terms are used consistently across all stories:
|
||||
- [ ] The event token, title, and date are also stored in localStorage alongside the RSVP data, so the local event overview (US-7) can display the event and link to it without server contact
|
||||
- [ ] If a prior RSVP exists in localStorage for this event, the form pre-fills with the previous choice and name
|
||||
- [ ] Re-submitting from the same device updates the existing RSVP entry rather than creating a duplicate
|
||||
- [ ] A honeypot field is present in the RSVP form: hidden from real users; any submission with the field populated is silently discarded server-side
|
||||
- [ ] RSVP submission is not possible after the event's expiry date
|
||||
- [ ] RSVP submission is not possible if the event has been cancelled (US-18) [deferred until US-18 is implemented]
|
||||
- [ ] No account, login, or data beyond the optionally entered name is required
|
||||
|
||||
**Dependencies:** US-2, T-4
|
||||
|
||||
**Notes:** RSVP flow specified in Ideen.md: "Ich komme" (with name) / "Ich komme nicht" (optional with name). LocalStorage device binding is the explicit duplicate-prevention mechanism — not a hard guarantee, but sufficient against accidental duplicates. Ideen.md acknowledges that malicious spam without accounts is an acceptable risk. Honeypot fields are listed under Ideen.md security measures.
|
||||
**Notes:** RSVP flow specified in Ideen.md: "Ich komme" (with name) / "Ich komme nicht" (optional with name). LocalStorage device binding is the explicit duplicate-prevention mechanism — not a hard guarantee, but sufficient against accidental duplicates. Ideen.md acknowledges that malicious spam without accounts is an acceptable risk.
|
||||
|
||||
**Addendum (2026-03-04):** Honeypot field removed — overengineered for this project's scope.
|
||||
|
||||
---
|
||||
|
||||
@@ -464,3 +466,21 @@ The following terms are used consistently across all stories:
|
||||
**Dependencies:** US-1, T-4
|
||||
|
||||
**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