Compare commits

..

2 Commits

Author SHA1 Message Date
Marcel
379bc84e11 fix(a11y): fix ProgressRing text label contrast and add no-restricted-syntax lint rule for text-accent
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m16s
CI / OCR Service Tests (push) Successful in 33s
CI / Backend Unit Tests (push) Failing after 3m2s
CI / Unit & Component Tests (pull_request) Failing after 3m0s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 2m55s
ProgressRing used text-accent (#a1dcd8) on a percentage text label —
same WCAG 2.1 AA failure as #341. Switched to text-primary.

Also adds ESLint no-restricted-syntax rule (scoped to *.svelte files) that
blocks future text-accent usage in JavaScript string literals inside Svelte
class expressions. The rule caught both violations at once; both are now fixed.
The rule is scoped to .svelte files so test assertions against 'text-accent'
strings in .spec.ts files are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:03:12 +02:00
Marcel
110da9b8b0 fix(viewer): replace text-accent with text-primary on annotation toggle inactive state
Fixes WCAG 2.1 AA contrast failure (#341): text-accent (#a1dcd8) on light
PDF control bar was 1.52:1 — well below the 4.5:1 AA minimum. text-primary
resolves to #012851 in light mode (14.5:1) and #a1dcd8 in dark mode (9:1) —
both states pass AA in both themes.

Adds PdfControls.svelte.spec.ts with 5 tests covering toggle visibility,
label strings, and the contrast-safe class assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:02:39 +02:00
192 changed files with 2554 additions and 19362 deletions

4
.gitignore vendored
View File

@@ -13,7 +13,3 @@ scripts/large-data.sql
.vitest-attachments
**/test-results/
.worktrees/
.superpowers/
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
frontend/yarn.lock

View File

@@ -1,3 +0,0 @@
### Mark all blocks as reviewed
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
Authorization: Basic admin admin123

View File

@@ -177,13 +177,6 @@
<artifactId>imageio-tiff</artifactId>
<version>3.12.0</version>
</dependency>
<!-- HTML sanitization for Geschichten rich-text body (defense-in-depth alongside Tiptap on the client) -->
<dependency>
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
<artifactId>owasp-java-html-sanitizer</artifactId>
<version>20240325.1</version>
</dependency>
</dependencies>

View File

@@ -1,69 +0,0 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.GeschichteService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/geschichten")
@RequiredArgsConstructor
public class GeschichteController {
private final GeschichteService geschichteService;
@GetMapping
public List<Geschichte> list(
@RequestParam(required = false) GeschichteStatus status,
@RequestParam(name = "personId", required = false) List<UUID> personIds,
@RequestParam(required = false) UUID documentId,
@RequestParam(required = false, defaultValue = "50") int limit) {
return geschichteService.list(
status,
personIds == null ? List.of() : personIds,
documentId,
limit);
}
@GetMapping("/{id}")
public Geschichte getById(@PathVariable UUID id) {
return geschichteService.getById(id);
}
@PostMapping
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
Geschichte created = geschichteService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PatchMapping("/{id}")
@RequirePermission(Permission.BLOG_WRITE)
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
return geschichteService.update(id, dto);
}
@DeleteMapping("/{id}")
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Void> delete(@PathVariable UUID id) {
geschichteService.delete(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -6,7 +6,6 @@ import jakarta.validation.ConstraintViolationException;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -48,12 +47,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleMessageNotReadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body"));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())

View File

@@ -34,13 +34,11 @@ public class PersonController {
private final DocumentService documentService;
@GetMapping
@RequirePermission(Permission.READ_ALL)
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
return ResponseEntity.ok(personService.findAll(q));
}
@GetMapping("/{id}")
@RequirePermission(Permission.READ_ALL)
public Person getPerson(@PathVariable UUID id) {
return personService.getById(id);
}

View File

@@ -1,6 +1,5 @@
package org.raddatz.familienarchiv.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
@@ -46,7 +45,7 @@ public class TranscriptionBlockController {
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock createBlock(
@PathVariable UUID documentId,
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
@RequestBody CreateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.createBlock(documentId, dto, userId);
@@ -57,7 +56,7 @@ public class TranscriptionBlockController {
public TranscriptionBlock updateBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
@RequestBody UpdateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
@@ -91,15 +90,6 @@ public class TranscriptionBlockController {
return transcriptionService.reviewBlock(documentId, blockId, userId);
}
@PutMapping("/review-all")
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> markAllBlocksReviewed(
@PathVariable UUID documentId,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.markAllBlocksReviewed(documentId, userId);
}
@GetMapping("/{blockId}/history")
@RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlockVersion> getBlockHistory(

View File

@@ -1,21 +1,14 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.raddatz.familienarchiv.model.PersonMention;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreateTranscriptionBlockDTO {
@Min(0)
private int pageNumber;
@@ -29,8 +22,4 @@ public class CreateTranscriptionBlockDTO {
private double height;
private String text;
private String label;
@Valid
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
}

View File

@@ -1,21 +0,0 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import java.util.List;
import java.util.UUID;
/**
* Used for both create and update of a Geschichte. All fields are optional;
* the service applies whatever is non-null. {@code body} is rich-text HTML and
* is sanitised against an allow-list before persistence.
*/
@Data
public class GeschichteUpdateDTO {
private String title;
private String body;
private GeschichteStatus status;
private List<UUID> personIds;
private List<UUID> documentIds;
}

View File

@@ -17,7 +17,6 @@ public interface PersonSummaryDTO {
Integer getBirthYear();
Integer getDeathYear();
String getNotes();
boolean isFamilyMember();
long getDocumentCount();
default String getDisplayName() {

View File

@@ -1,24 +1,13 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.raddatz.familienarchiv.model.PersonMention;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UpdateTranscriptionBlockDTO {
private String text;
private String label;
@Valid
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
}

View File

@@ -15,6 +15,7 @@ public enum ErrorCode {
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,
@@ -95,18 +96,6 @@ public enum ErrorCode {
/** Internal inconsistency: expected training run row was not found after creation. 500 */
OCR_TRAINING_CONFLICT,
// --- Relationships (Stammbaum) ---
/** A relationship row with the given ID does not exist. 404 */
RELATIONSHIP_NOT_FOUND,
/** Adding this relationship would create a cycle (e.g. reverse PARENT_OF already exists). 409 */
CIRCULAR_RELATIONSHIP,
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
DUPLICATE_RELATIONSHIP,
// --- Geschichten (Stories) ---
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
GESCHICHTE_NOT_FOUND,
// --- Tags ---
/** A tag with the given ID does not exist. 404 */
TAG_NOT_FOUND,

View File

@@ -1,69 +0,0 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "geschichten")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Geschichte {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String title;
@Column(columnDefinition = "TEXT")
private String body;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private GeschichteStatus status = GeschichteStatus.DRAFT;
@ManyToOne
@JoinColumn(name = "author_id")
private AppUser author;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "geschichten_persons",
joinColumns = @JoinColumn(name = "geschichte_id"),
inverseJoinColumns = @JoinColumn(name = "person_id"))
@Builder.Default
private Set<Person> persons = new HashSet<>();
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "geschichten_documents",
joinColumns = @JoinColumn(name = "geschichte_id"),
inverseJoinColumns = @JoinColumn(name = "document_id"))
@Builder.Default
private Set<Document> documents = new HashSet<>();
@CreationTimestamp
@Column(updatable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
@UpdateTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updatedAt;
@Column(name = "published_at")
private LocalDateTime publishedAt;
}

View File

@@ -1,6 +0,0 @@
package org.raddatz.familienarchiv.model;
public enum GeschichteStatus {
DRAFT,
PUBLISHED
}

View File

@@ -47,11 +47,6 @@ public class Person {
private Integer birthYear;
private Integer deathYear;
@Column(name = "family_member", nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean familyMember = false;
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
// Uses entity relationship rather than cross-domain repository access, avoiding a
// separate DB roundtrip while respecting domain boundaries.

View File

@@ -1,31 +0,0 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Embeddable
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PersonMention {
@NotNull
@Column(name = "person_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID personId;
@NotNull
@Size(max = 200)
@Column(name = "display_name", nullable = false, length = 200)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
// Archival: the text the transcriber typed after @. Never updated on person rename.
private String displayName;
}

View File

@@ -7,8 +7,6 @@ import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@@ -35,16 +33,6 @@ public class TranscriptionBlock {
@Column(columnDefinition = "TEXT")
private String text;
// EAGER: mention set is bounded by block text length (typically < 20 entries).
// Switching back to LAZY requires callers to be inside an open Hibernate session.
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "transcription_block_mentioned_persons",
joinColumns = @JoinColumn(name = "block_id"))
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
@Column(length = 200)
private String label;

View File

@@ -1,55 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.raddatz.familienarchiv.model.Person;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "person_relationships")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = "notes")
public class PersonRelationship {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "person_id", nullable = false)
@JsonIgnore
private Person person;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "related_person_id", nullable = false)
@JsonIgnore
private Person relatedPerson;
@Enumerated(EnumType.STRING)
@Column(name = "relation_type", nullable = false, length = 30)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private RelationType relationType;
@Column(name = "from_year")
private Integer fromYear;
@Column(name = "to_year")
private Integer toYear;
@Column(length = 2000)
private String notes;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Instant createdAt;
}

View File

@@ -1,49 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Repository
public interface PersonRelationshipRepository extends JpaRepository<PersonRelationship, UUID> {
/**
* Bulk fetch for the network endpoint — pulls only edges of the given types.
* The service filters by family_member afterwards.
*/
@Query("SELECT r FROM PersonRelationship r " +
"JOIN FETCH r.person " +
"JOIN FETCH r.relatedPerson " +
"WHERE r.relationType IN :types")
List<PersonRelationship> findAllByRelationTypeIn(@Param("types") Collection<RelationType> types);
/** Used for the circular-PARENT_OF check in {@code addRelationship}. */
boolean existsByPersonIdAndRelatedPersonIdAndRelationType(
UUID personId, UUID relatedPersonId, RelationType relationType);
/**
* All edges incident on {@code personId} (either side) restricted to the given types.
* Used by the inference service to load a person's local subgraph for BFS.
*/
@Query("SELECT r FROM PersonRelationship r " +
"WHERE (r.person.id = :personId OR r.relatedPerson.id = :personId) " +
"AND r.relationType IN :types")
List<PersonRelationship> findAllByPersonIdOrRelatedPersonIdAndRelationTypeIn(
@Param("personId") UUID personId,
@Param("types") Collection<RelationType> types);
/**
* All edges incident on {@code personId} (either side), all types.
* Used by the "direct relationships" listings (person edit, side panel).
*/
@Query("SELECT r FROM PersonRelationship r " +
"JOIN FETCH r.person " +
"JOIN FETCH r.relatedPerson " +
"WHERE r.person.id = :personId OR r.relatedPerson.id = :personId")
List<PersonRelationship> findAllByPersonIdOrRelatedPersonId(@Param("personId") UUID personId);
}

View File

@@ -1,24 +0,0 @@
package org.raddatz.familienarchiv.relationship;
/**
* Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}.
* A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}.
*
* <p>Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric
* tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched.
*/
public enum RelationToken {
UP,
DOWN,
SPOUSE,
SIBLING;
public RelationToken reverse() {
return switch (this) {
case UP -> DOWN;
case DOWN -> UP;
case SPOUSE -> SPOUSE;
case SIBLING -> SIBLING;
};
}
}

View File

@@ -1,20 +0,0 @@
package org.raddatz.familienarchiv.relationship;
/**
* Family-network relationship taxonomy.
*
* <p>Symmetric types ({@link #SPOUSE_OF}, {@link #SIBLING_OF}) are stored once;
* the inference service walks them in both directions. {@link #PARENT_OF} is
* directional: A PARENT_OF B means A is the parent.
*/
public enum RelationType {
PARENT_OF,
SPOUSE_OF,
SIBLING_OF,
FRIEND,
COLLEAGUE,
EMPLOYER,
DOCTOR,
NEIGHBOR,
OTHER
}

View File

@@ -1,84 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.FamilyMemberPatchDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
/**
* Stammbaum API. Endpoints split across two roots:
* <ul>
* <li>{@code /api/network} — the family graph</li>
* <li>{@code /api/persons/{id}/...} — per-person relationship operations
* (PersonController is intentionally left untouched)</li>
* </ul>
*/
@RestController
@RequiredArgsConstructor
public class RelationshipController {
private final RelationshipService relationshipService;
// READ endpoints carry no @RequirePermission: all authenticated users may read the family graph.
// Unauthenticated requests are rejected by Spring Security's anyRequest().authenticated() rule.
@GetMapping("/api/network")
public NetworkDTO getNetwork() {
return relationshipService.getFamilyNetwork();
}
@GetMapping("/api/persons/{id}/relationships")
public List<RelationshipDTO> getRelationships(@PathVariable UUID id) {
return relationshipService.getRelationships(id);
}
@GetMapping("/api/persons/{id}/inferred-relationships")
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(@PathVariable UUID id) {
return relationshipService.getInferredRelationships(id);
}
@GetMapping("/api/persons/{aId}/relationship-to/{bId}")
public InferredRelationshipDTO getRelationshipBetween(@PathVariable UUID aId, @PathVariable UUID bId) {
return relationshipService.getRelationshipBetween(aId, bId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "No relationship path between " + aId + " and " + bId));
}
@PostMapping("/api/persons/{id}/relationships")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<RelationshipDTO> addRelationship(
@PathVariable UUID id,
@Valid @RequestBody CreateRelationshipRequest dto) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(relationshipService.addRelationship(id, dto));
}
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void deleteRelationship(@PathVariable UUID id, @PathVariable UUID relId) {
relationshipService.deleteRelationship(id, relId);
}
@PatchMapping("/api/persons/{id}/family-member")
@RequirePermission(Permission.WRITE_ALL)
public Person patchFamilyMember(@PathVariable UUID id, @RequestBody FamilyMemberPatchDTO dto) {
return relationshipService.setFamilyMember(id, dto.familyMember());
}
}

View File

@@ -1,211 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* Derives indirect family relationships by BFS over the family-graph subset
* (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are
* not consulted. Siblings are also derived from shared parents — no SIBLING_OF
* row is required.
*/
@Service
@RequiredArgsConstructor
public class RelationshipInferenceService {
// 8 hops covers great-grandparents ↔ great-great-grandchildren and second cousins —
// the practical horizon for a 18991950 family archive. Paths longer than this are
// classified as LABEL_DISTANT and rarely carry meaningful relationship labels.
static final int MAX_DEPTH = 8;
/** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */
static final String LABEL_DISTANT = "distant";
private static final Map<List<RelationToken>, String> LABEL_MAP = buildLabelMap();
private final PersonRelationshipRepository relationshipRepository;
private final PersonService personService;
private static Map<List<RelationToken>, String> buildLabelMap() {
Map<List<RelationToken>, String> m = new HashMap<>();
m.put(List.of(RelationToken.UP), "parent");
m.put(List.of(RelationToken.DOWN), "child");
m.put(List.of(RelationToken.SPOUSE), "spouse");
m.put(List.of(RelationToken.SIBLING), "sibling");
m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent");
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild");
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent");
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild");
m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt");
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew");
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt");
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew");
m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent");
m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child");
m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw");
m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw");
m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1");
return Collections.unmodifiableMap(m);
}
/**
* Shortest token path from {@code from} to {@code to}, or empty if unreachable
* within {@link #MAX_DEPTH} hops. Package-private to permit direct path
* assertions in unit tests.
*/
Optional<List<RelationToken>> findShortestPath(UUID from, UUID to) {
if (from.equals(to)) return Optional.empty();
Map<UUID, List<Edge>> adj = buildAdjacency();
return bfs(adj, from, to);
}
/** Two-sided label between A and B. {@code labelFromA} reads "B is my <labelFromA>". */
public Optional<InferredRelationshipDTO> infer(UUID a, UUID b) {
Optional<List<RelationToken>> aToB = findShortestPath(a, b);
if (aToB.isEmpty()) return Optional.empty();
List<RelationToken> path = aToB.get();
return Optional.of(new InferredRelationshipDTO(
labelFor(path),
labelFor(reversePath(path)),
path.size()));
}
/** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */
public List<InferredRelationshipWithPersonDTO> findAllFor(UUID personId) {
Map<UUID, List<Edge>> adj = buildAdjacency();
Map<UUID, List<RelationToken>> shortestPaths = bfsAll(adj, personId);
shortestPaths.remove(personId);
if (shortestPaths.isEmpty()) return List.of();
List<UUID> ids = new ArrayList<>(shortestPaths.keySet());
Map<UUID, Person> byId = new HashMap<>();
for (Person p : personService.getAllById(ids)) {
byId.put(p.getId(), p);
}
List<InferredRelationshipWithPersonDTO> out = new ArrayList<>();
for (UUID id : ids) {
Person p = byId.get(id);
if (p == null) continue;
List<RelationToken> path = shortestPaths.get(id);
PersonNodeDTO node = new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
}
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
.thenComparing(d -> d.person().displayName()));
return out;
}
static String labelFor(List<RelationToken> path) {
String specific = LABEL_MAP.get(path);
return specific != null ? specific : LABEL_DISTANT;
}
private static List<RelationToken> reversePath(List<RelationToken> path) {
List<RelationToken> reversed = new ArrayList<>(path.size());
for (int i = path.size() - 1; i >= 0; i--) {
reversed.add(path.get(i).reverse());
}
return List.copyOf(reversed);
}
private Map<UUID, List<Edge>> buildAdjacency() {
List<PersonRelationship> edges = relationshipRepository.findAllByRelationTypeIn(
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
Map<UUID, List<Edge>> adj = new HashMap<>();
Map<UUID, List<UUID>> parentToChildren = new HashMap<>();
for (PersonRelationship e : edges) {
UUID a = e.getPerson().getId();
UUID b = e.getRelatedPerson().getId();
switch (e.getRelationType()) {
case PARENT_OF -> {
addEdge(adj, a, b, RelationToken.DOWN);
addEdge(adj, b, a, RelationToken.UP);
parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
}
case SPOUSE_OF -> {
addEdge(adj, a, b, RelationToken.SPOUSE);
addEdge(adj, b, a, RelationToken.SPOUSE);
}
case SIBLING_OF -> {
addEdge(adj, a, b, RelationToken.SIBLING);
addEdge(adj, b, a, RelationToken.SIBLING);
}
default -> { /* family graph excludes other types */ }
}
}
for (List<UUID> children : parentToChildren.values()) {
for (int i = 0; i < children.size(); i++) {
for (int j = i + 1; j < children.size(); j++) {
UUID c1 = children.get(i);
UUID c2 = children.get(j);
addEdge(adj, c1, c2, RelationToken.SIBLING);
addEdge(adj, c2, c1, RelationToken.SIBLING);
}
}
}
return adj;
}
private static void addEdge(Map<UUID, List<Edge>> adj, UUID from, UUID to, RelationToken token) {
adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token));
}
private static Optional<List<RelationToken>> bfs(Map<UUID, List<Edge>> adj, UUID from, UUID to) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> currPath = shortest.get(curr);
if (currPath.size() >= MAX_DEPTH) continue;
for (Edge e : adj.getOrDefault(curr, List.of())) {
if (shortest.containsKey(e.target())) continue;
List<RelationToken> nextPath = append(currPath, e.token());
shortest.put(e.target(), nextPath);
if (e.target().equals(to)) return Optional.of(nextPath);
queue.add(e.target());
}
}
return Optional.empty();
}
private static Map<UUID, List<RelationToken>> bfsAll(Map<UUID, List<Edge>> adj, UUID from) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> currPath = shortest.get(curr);
if (currPath.size() >= MAX_DEPTH) continue;
for (Edge e : adj.getOrDefault(curr, List.of())) {
if (shortest.containsKey(e.target())) continue;
List<RelationToken> nextPath = append(currPath, e.token());
shortest.put(e.target(), nextPath);
queue.add(e.target());
}
}
return shortest;
}
private static List<RelationToken> append(List<RelationToken> prefix, RelationToken next) {
List<RelationToken> out = new ArrayList<>(prefix.size() + 1);
out.addAll(prefix);
out.add(next);
return List.copyOf(out);
}
private record Edge(UUID target, RelationToken token) {}
}

View File

@@ -1,168 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
/**
* Owns the {@code person_relationships} table and the family_member flag.
* Always orchestrates {@link PersonService} for cross-domain access — never
* touches {@link org.raddatz.familienarchiv.repository.PersonRepository}.
*/
@Service
@RequiredArgsConstructor
public class RelationshipService {
private final PersonRelationshipRepository relationshipRepository;
private final PersonService personService;
private final RelationshipInferenceService inferenceService;
public List<RelationshipDTO> getRelationships(UUID personId) {
personService.getById(personId);
List<PersonRelationship> rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId);
return rels.stream().map(RelationshipService::toDTO).toList();
}
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(UUID personId) {
personService.getById(personId);
return inferenceService.findAllFor(personId);
}
public Optional<InferredRelationshipDTO> getRelationshipBetween(UUID a, UUID b) {
personService.getById(a);
personService.getById(b);
return inferenceService.infer(a, b);
}
public NetworkDTO getFamilyNetwork() {
// Two queries: 1 for nodes (family members), 1 for edges (family-graph types).
List<Person> familyMembers = personService.findAllFamilyMembers();
Set<UUID> familyIds = new HashSet<>(familyMembers.size());
List<PersonNodeDTO> nodes = new ArrayList<>(familyMembers.size());
for (Person p : familyMembers) {
familyIds.add(p.getId());
nodes.add(new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
}
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
List<RelationshipDTO> edges = new ArrayList<>();
for (PersonRelationship r : familyEdges) {
UUID p = r.getPerson().getId();
UUID rp = r.getRelatedPerson().getId();
if (familyIds.contains(p) && familyIds.contains(rp)) {
edges.add(toDTO(r));
}
}
return new NetworkDTO(nodes, edges);
}
@Transactional
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
if (personId.equals(dto.relatedPersonId())) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
Person person = personService.getById(personId);
Person relatedPerson = personService.getById(dto.relatedPersonId());
validateYears(dto.fromYear(), dto.toYear());
if (dto.relationType() == RelationType.PARENT_OF
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
throw DomainException.conflict(
ErrorCode.CIRCULAR_RELATIONSHIP,
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
}
PersonRelationship rel = PersonRelationship.builder()
.person(person)
.relatedPerson(relatedPerson)
.relationType(dto.relationType())
.fromYear(dto.fromYear())
.toYear(dto.toYear())
.notes(blankToNull(dto.notes()))
.build();
try {
// saveAndFlush so the unique_rel constraint violates synchronously and is
// caught here, not at commit time outside the @Transactional boundary.
return toDTO(relationshipRepository.saveAndFlush(rel));
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(
ErrorCode.DUPLICATE_RELATIONSHIP,
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
}
}
@Transactional
public void deleteRelationship(UUID personId, UUID relId) {
PersonRelationship rel = relationshipRepository.findById(relId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
UUID storageSubject = rel.getPerson().getId();
UUID storageObject = rel.getRelatedPerson().getId();
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
throw DomainException.forbidden(
"Relationship " + relId + " does not belong to person " + personId);
}
relationshipRepository.delete(rel);
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
return personService.setFamilyMember(personId, familyMember);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
private static void validateYears(Integer fromYear, Integer toYear) {
if (fromYear != null && toYear != null && toYear < fromYear) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
}
}
private static RelationshipDTO toDTO(PersonRelationship r) {
Person p = r.getPerson();
Person rp = r.getRelatedPerson();
return new RelationshipDTO(
r.getId(),
p.getId(),
rp.getId(),
p.getDisplayName(),
p.getBirthYear(),
p.getDeathYear(),
rp.getDisplayName(),
rp.getBirthYear(),
rp.getDeathYear(),
r.getRelationType(),
r.getFromYear(),
r.getToYear(),
r.getNotes());
}
}

View File

@@ -1,15 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.raddatz.familienarchiv.relationship.RelationType;
import java.util.UUID;
public record CreateRelationshipRequest(
@NotNull UUID relatedPersonId,
@NotNull RelationType relationType,
Integer fromYear,
Integer toYear,
@Size(max = 2000) String notes
) {}

View File

@@ -1,4 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
public record FamilyMemberPatchDTO(boolean familyMember) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Pairwise inferred relationship for the document badge.
* {@code labelFromA} reads "Person B, from A's point of view" and vice-versa
* (e.g. A=parent, B=child → labelFromA = "Sohn", labelFromB = "Vater").
*/
public record InferredRelationshipDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromA,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromB,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Used by {@code GET /api/persons/{id}/inferred-relationships}: each entry
* is a derived relationship to another family member, labelled from the
* requesting person's perspective.
*/
public record InferredRelationshipWithPersonDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) PersonNodeDTO person,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String label,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
) {}

View File

@@ -1,11 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** Payload for {@code GET /api/network}. Nodes are family members; edges are family-graph relationships. */
public record NetworkDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonNodeDTO> nodes,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<RelationshipDTO> edges
) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
/** Lightweight node for the Stammbaum tree and inferred-relationship payloads. */
public record PersonNodeDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
Integer birthYear,
Integer deathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
) {}

View File

@@ -1,32 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.relationship.RelationType;
import java.util.UUID;
/**
* Wire shape for one stored relationship row. Both sides include name + years
* so the frontend can render the row from either perspective (e.g. on the
* subject's page the row reads "Elternteil von [related]"; on the object's
* page it reads "Kind von [person]").
*
* <p>Storage truth: {@code personId} is the {@code person_id} column,
* {@code relatedPersonId} is the {@code related_person_id} column. The
* frontend determines orientation by comparing against the viewpoint.
*/
public record RelationshipDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID relatedPersonId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String personDisplayName,
Integer personBirthYear,
Integer personDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String relatedPersonDisplayName,
Integer relatedPersonBirthYear,
Integer relatedPersonDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
Integer fromYear,
Integer toYear,
String notes
) {}

View File

@@ -1,12 +0,0 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.Geschichte;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
}

View File

@@ -1,91 +0,0 @@
package org.raddatz.familienarchiv.repository;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.springframework.data.jpa.domain.Specification;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
public final class GeschichteSpecifications {
private GeschichteSpecifications() {}
public static Specification<Geschichte> hasStatus(GeschichteStatus status) {
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
}
/**
* Adds {@code ORDER BY COALESCE(publishedAt, updatedAt) DESC} to the query without contributing
* a predicate. Combined into the spec chain via {@code .and(...)}; the {@code conjunction}
* acts as a no-op WHERE clause.
*/
public static Specification<Geschichte> orderByDisplayDateDesc() {
return (root, query, cb) -> {
// Skip ordering on count queries — JPA forbids orderBy on COUNT projections.
if (query != null
&& Long.class != query.getResultType()
&& long.class != query.getResultType()) {
query.orderBy(cb.desc(cb.coalesce(root.get("publishedAt"), root.get("updatedAt"))));
}
return cb.conjunction();
};
}
public static Specification<Geschichte> hasDocument(UUID documentId) {
return (root, query, cb) -> {
if (documentId == null) return null;
return cb.exists(documentSubquery(root, query, cb, documentId));
};
}
/**
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
*
* <p>Implemented as one EXISTS subquery per id (canonical Criteria-API idiom for AND across a
* many-to-many join). Mirrors {@link DocumentSpecifications#hasTags} which uses the same shape.
* Empty / null input returns {@code null} (i.e. no constraint added).
*/
public static Specification<Geschichte> hasAllPersons(Collection<UUID> personIds) {
return (root, query, cb) -> {
if (personIds == null || personIds.isEmpty()) return null;
List<Predicate> predicates = new ArrayList<>(personIds.size());
for (UUID id : personIds) {
predicates.add(cb.exists(personSubquery(root, query, cb, id)));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
private static Subquery<UUID> personSubquery(
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID personId) {
Subquery<UUID> sub = query.subquery(UUID.class);
Root<Geschichte> subRoot = sub.from(Geschichte.class);
Join<Geschichte, Person> persons = subRoot.join("persons");
sub.select(subRoot.get("id"))
.where(cb.equal(subRoot.get("id"), root.get("id")),
cb.equal(persons.get("id"), personId));
return sub;
}
private static Subquery<UUID> documentSubquery(
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
Subquery<UUID> sub = query.subquery(UUID.class);
Root<Geschichte> subRoot = sub.from(Geschichte.class);
Join<Geschichte, Document> documents = subRoot.join("documents");
sub.select(subRoot.get("id"))
.where(cb.equal(subRoot.get("id"), root.get("id")),
cb.equal(documents.get("id"), documentId));
return sub;
}
}

View File

@@ -26,9 +26,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
// Stammbaum-Knoten: alle Personen mit family_member = true.
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
@@ -41,7 +38,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
@@ -54,7 +50,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
@@ -63,7 +58,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)

View File

@@ -29,15 +29,6 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
@Query("""
SELECT DISTINCT b FROM TranscriptionBlock b
JOIN FETCH b.mentionedPersons
WHERE b.id IN (
SELECT bb.id FROM TranscriptionBlock bb JOIN bb.mentionedPersons m WHERE m.personId = :personId
)
""")
List<TranscriptionBlock> findByPersonIdWithMentionsFetched(@Param("personId") UUID personId);
void deleteByAnnotationId(UUID annotationId);
int countByDocumentId(UUID documentId);
@@ -60,25 +51,21 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
""")
List<TranscriptionBlock> findSegmentationBlocks();
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
@Query("""
SELECT COUNT(b) FROM TranscriptionBlock b
JOIN Document d ON d.id = b.documentId
WHERE b.source = 'MANUAL'
AND d.sender.id = :personId
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
AND d.scriptType = 'HANDWRITING_KURRENT'
""")
long countManualKurrentBlocksByPerson(@Param("personId") UUID personId);
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
@Query("""
SELECT b FROM TranscriptionBlock b
JOIN Document d ON d.id = b.documentId
WHERE b.source = 'MANUAL'
AND d.sender.id = :personId
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
AND d.scriptType = 'HANDWRITING_KURRENT'
""")
List<TranscriptionBlock> findManualKurrentBlocksByPerson(@Param("personId") UUID personId);
}

View File

@@ -4,7 +4,6 @@ public enum Permission {
READ_ALL,
WRITE_ALL,
ANNOTATE_ALL,
BLOG_WRITE,
ADMIN,
ADMIN_USER,
ADMIN_TAG,

View File

@@ -1,193 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.GeschichteRepository;
import org.raddatz.familienarchiv.repository.GeschichteSpecifications;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class GeschichteService {
private final GeschichteRepository geschichteRepository;
private final PersonService personService;
private final DocumentService documentService;
private final UserService userService;
/**
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
* already constrains the marks/nodes, but the backend re-sanitises every
* save so that an attacker calling the API directly cannot inject more.
*/
private static final PolicyFactory BODY_SANITIZER = new HtmlPolicyBuilder()
.allowElements("p", "br", "strong", "em", "h2", "h3", "ul", "ol", "li")
.toFactory();
private static final int DEFAULT_LIMIT = 50;
private static final int MAX_LIMIT = 200;
// ─── Read API ────────────────────────────────────────────────────────────
public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
if (g.getStatus() == GeschichteStatus.DRAFT && !currentUserHasBlogWrite()) {
// Use NOT_FOUND, not FORBIDDEN — don't leak DRAFT existence.
throw DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
}
return g;
}
/**
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
* must be associated with every person id supplied. An empty or null list applies no
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
*/
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
Specification<Geschichte> spec = Specification.allOf(
GeschichteSpecifications.hasStatus(effective),
GeschichteSpecifications.hasAllPersons(personIds),
GeschichteSpecifications.hasDocument(documentId),
GeschichteSpecifications.orderByDisplayDateDesc()
);
return geschichteRepository.findAll(spec, Sort.unsorted())
.stream()
.limit(safeLimit)
.toList();
}
// ─── Write API ───────────────────────────────────────────────────────────
@Transactional
public Geschichte create(GeschichteUpdateDTO dto) {
requireTitle(dto.getTitle());
Geschichte g = Geschichte.builder()
.title(dto.getTitle().trim())
.body(sanitize(dto.getBody()))
.status(GeschichteStatus.DRAFT)
.author(currentUser())
.persons(resolvePersons(dto.getPersonIds()))
.documents(resolveDocuments(dto.getDocumentIds()))
.build();
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
g.setStatus(GeschichteStatus.PUBLISHED);
g.setPublishedAt(LocalDateTime.now());
}
return geschichteRepository.save(g);
}
@Transactional
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
if (dto.getTitle() != null) {
requireTitle(dto.getTitle());
g.setTitle(dto.getTitle().trim());
}
if (dto.getBody() != null) {
g.setBody(sanitize(dto.getBody()));
}
if (dto.getPersonIds() != null) {
g.setPersons(resolvePersons(dto.getPersonIds()));
}
if (dto.getDocumentIds() != null) {
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
}
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
applyStatusTransition(g, dto.getStatus());
}
return geschichteRepository.save(g);
}
@Transactional
public void delete(UUID id) {
if (!geschichteRepository.existsById(id)) {
throw DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
}
geschichteRepository.deleteById(id);
}
// ─── private helpers ─────────────────────────────────────────────────────
private void applyStatusTransition(Geschichte g, GeschichteStatus next) {
g.setStatus(next);
if (next == GeschichteStatus.PUBLISHED) {
g.setPublishedAt(LocalDateTime.now());
} else {
g.setPublishedAt(null);
}
}
private void requireTitle(String title) {
if (title == null || title.trim().isEmpty()) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Title is required");
}
}
private String sanitize(String body) {
if (body == null) return null;
return BODY_SANITIZER.sanitize(body);
}
private Set<Person> resolvePersons(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
return new LinkedHashSet<>(personService.getAllById(ids));
}
private Set<Document> resolveDocuments(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
Set<Document> out = new LinkedHashSet<>();
for (UUID id : ids) {
out.add(documentService.getDocumentById(id));
}
return out;
}
private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
return userService.findByEmail(auth.getName());
}
private boolean currentUserHasBlogWrite() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> Permission.BLOG_WRITE.name().equals(a.getAuthority()));
}
}

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -57,17 +58,6 @@ public class PersonService {
return personRepository.findAllById(ids);
}
public List<Person> findAllFamilyMembers() {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
Person person = getById(personId);
person.setFamilyMember(familyMember);
return personRepository.save(person);
}
public Optional<Person> findByName(String firstName, String lastName) {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
}

View File

@@ -134,8 +134,6 @@ public class TranscriptionService {
if (dto.getLabel() != null) {
block.setLabel(dto.getLabel());
}
block.getMentionedPersons().clear();
block.getMentionedPersons().addAll(dto.getMentionedPersons());
block.setUpdatedBy(userId);
TranscriptionBlock saved = blockRepository.save(block);
@@ -207,18 +205,6 @@ public class TranscriptionService {
return saved;
}
@Transactional
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
for (TranscriptionBlock block : blocks) {
if (!block.isReviewed()) {
block.setReviewed(true);
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
}
}
return blockRepository.saveAll(blocks);
}
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
getBlock(documentId, blockId);
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);

View File

@@ -1,30 +0,0 @@
-- Family network: marks a Person as a tree node and stores typed relationships
-- between two persons. The tree page (/stammbaum) only shows persons with
-- family_member = TRUE. Symmetric types (SPOUSE_OF, SIBLING_OF) are stored once;
-- the partial unique index keeps SIBLING_OF pairs from being duplicated in the
-- reverse direction.
ALTER TABLE persons
ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE person_relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
related_person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
relation_type VARCHAR(30) NOT NULL,
from_year INTEGER,
to_year INTEGER,
notes VARCHAR(2000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT no_self_rel CHECK (person_id <> related_person_id),
CONSTRAINT unique_rel UNIQUE (person_id, related_person_id, relation_type)
);
CREATE INDEX idx_person_rel_person_id ON person_relationships(person_id);
CREATE INDEX idx_person_rel_related_person_id ON person_relationships(related_person_id);
-- Symmetric SIBLING_OF: enforce only one row per unordered pair.
CREATE UNIQUE INDEX unique_sibling_pair ON person_relationships (
LEAST(person_id, related_person_id),
GREATEST(person_id, related_person_id)
) WHERE relation_type = 'SIBLING_OF';

View File

@@ -1,6 +0,0 @@
-- Symmetric SPOUSE_OF: enforce only one row per unordered pair, mirroring the
-- SIBLING_OF index added in V54.
CREATE UNIQUE INDEX unique_spouse_pair ON person_relationships (
LEAST(person_id, related_person_id),
GREATEST(person_id, related_person_id)
) WHERE relation_type = 'SPOUSE_OF';

View File

@@ -1,25 +0,0 @@
-- Sidecar table for @-mentions inside transcription_blocks.text.
-- Each row is one (block_id, person_id, display_name) tuple emitted by the
-- typeahead in the transcription editor. block.text contains the literal
-- "@DisplayName" — the UUID lives only here so historical text stays clean.
--
-- Schema choice: child table via @ElementCollection (mirrors the established
-- UserGroup.permissions / group_permissions pattern), NOT JSONB. The "show
-- all blocks mentioning person X" query on the person detail page joins on
-- the indexed person_id column — equally fast as JSONB GIN containment, no
-- new dependency. document_comments.comment_mentions stays as a many-to-many
-- to AppUser; the divergence is intentional: Person mentions need lazy
-- degradation when a person is deleted (no FK), while user mentions don't.
--
-- No FK on person_id: when a Person is deleted we want @Auguste Raddatz to
-- remain visible as plain unlinked text inside the transcription rather than
-- vanishing or cascade-deleting the block.
CREATE TABLE transcription_block_mentioned_persons (
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
person_id UUID NOT NULL,
display_name VARCHAR(200) NOT NULL
);
CREATE INDEX idx_tbmp_person_id ON transcription_block_mentioned_persons(person_id);
CREATE INDEX idx_tbmp_block_id ON transcription_block_mentioned_persons(block_id);

View File

@@ -1,5 +0,0 @@
-- Prevent duplicate sidecar rows for the same (block, person) pair.
-- @ElementCollection uses DELETE+INSERT per update so normal JPA writes can't
-- create duplicates, but a raw-SQL import or concurrent bypass of JPA could.
ALTER TABLE transcription_block_mentioned_persons
ADD CONSTRAINT uq_tbmp_block_person UNIQUE (block_id, person_id);

View File

@@ -1,34 +0,0 @@
-- Geschichten: blog-like family memory stories linked to persons and documents (issue #381).
-- BLOG_WRITE permission gates authoring; DRAFT stories are never returned to readers.
CREATE TABLE geschichten (
id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
body TEXT,
status VARCHAR(32) NOT NULL,
author_id UUID REFERENCES users (id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
published_at TIMESTAMP
);
CREATE TABLE geschichten_persons (
geschichte_id UUID NOT NULL REFERENCES geschichten (id) ON DELETE CASCADE,
person_id UUID NOT NULL REFERENCES persons (id) ON DELETE CASCADE,
PRIMARY KEY (geschichte_id, person_id)
);
CREATE TABLE geschichten_documents (
geschichte_id UUID NOT NULL REFERENCES geschichten (id) ON DELETE CASCADE,
document_id UUID NOT NULL REFERENCES documents (id) ON DELETE CASCADE,
PRIMARY KEY (geschichte_id, document_id)
);
-- Index page query: WHERE status = 'PUBLISHED' ORDER BY published_at DESC.
CREATE INDEX idx_geschichten_published
ON geschichten (published_at DESC)
WHERE status = 'PUBLISHED';
-- Reverse-lookup indexes for the ?personId / ?documentId filters.
CREATE INDEX idx_geschichten_persons_person ON geschichten_persons (person_id);
CREATE INDEX idx_geschichten_documents_document ON geschichten_documents (document_id);

View File

@@ -1,16 +0,0 @@
-- Grant BLOG_WRITE to every existing group that already holds WRITE_ALL.
-- Without this, the Geschichten feature ships dark to production: no group
-- has BLOG_WRITE, so the editor controls are invisible and "+ Neue Geschichte"
-- is never rendered. The natural mapping is "groups that can already write
-- documents and tags can also author family stories." Admins can revoke or
-- re-assign via the group editor afterwards.
INSERT INTO group_permissions (group_id, permission)
SELECT DISTINCT gp.group_id, 'BLOG_WRITE'
FROM group_permissions gp
WHERE gp.permission = 'WRITE_ALL'
AND NOT EXISTS (
SELECT 1 FROM group_permissions existing
WHERE existing.group_id = gp.group_id
AND existing.permission = 'BLOG_WRITE'
);

View File

@@ -154,13 +154,6 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {

View File

@@ -1,237 +0,0 @@
package org.raddatz.familienarchiv.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.GeschichteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(GeschichteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class GeschichteControllerTest {
@Autowired
MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean
GeschichteService geschichteService;
@MockitoBean
CustomUserDetailsService customUserDetailsService;
// ─── GET /api/geschichten ────────────────────────────────────────────────
@Test
void list_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_returns200_forReader() throws Exception {
when(geschichteService.list(any(), any(), any(), anyInt()))
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Story A"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
UUID personId = UUID.randomUUID();
when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten")
.param("personId", a.toString())
.param("personId", b.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void getById_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
mockMvc.perform(get("/api/geschichten/{id}", id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id.toString()))
.andExpect(jsonPath("$.title").value("Hello"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id))
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
mockMvc.perform(get("/api/geschichten/{id}", id))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GESCHICHTE_NOT_FOUND"));
}
// ─── POST /api/geschichten ───────────────────────────────────────────────
@Test
void create_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/geschichten")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"x\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void create_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(post("/api/geschichten")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void create_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
.thenReturn(draft(id, "New"));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("New");
mockMvc.perform(post("/api/geschichten")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(id.toString()));
}
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void update_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void update_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
.thenReturn(published(id, "Updated"));
mockMvc.perform(patch("/api/geschichten/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PUBLISHED"));
}
// ─── DELETE /api/geschichten/{id} ────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void delete_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void delete_returns204_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(delete("/api/geschichten/{id}", id))
.andExpect(status().isNoContent());
verify(geschichteService).delete(id);
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte published(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.body("<p>x</p>")
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
private Geschichte draft(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.status(GeschichteStatus.DRAFT)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
}

View File

@@ -57,13 +57,6 @@ class PersonControllerTest {
@Test
@WithMockUser
void getPersons_returns403_whenMissingReadAllPermission() throws Exception {
mockMvc.perform(get("/api/persons"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_returns200_withEmptyList() throws Exception {
when(personService.findAll(null)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons"))
@@ -71,7 +64,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser(authorities = "READ_ALL")
@WithMockUser
void getPersons_delegatesQueryParam_toService() throws Exception {
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
when(personService.findAll("Hans")).thenReturn(List.of(dto));
@@ -92,7 +85,6 @@ class PersonControllerTest {
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
public String getNotes() { return null; }
public boolean isFamilyMember() { return false; }
public long getDocumentCount() { return 0; }
};
}
@@ -107,13 +99,6 @@ class PersonControllerTest {
@Test
@WithMockUser
void getPerson_returns403_whenMissingReadAllPermission() throws Exception {
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPerson_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();

View File

@@ -183,36 +183,6 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String longName = "A".repeat(201);
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
+ "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
@Test
@@ -251,34 +221,6 @@ class TranscriptionBlockControllerTest {
.andExpect(jsonPath("$.label").value("Anrede"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String longName = "A".repeat(201);
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
@@ -318,13 +260,6 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deleteBlock_returns204_whenAuthorised() throws Exception {
@@ -438,63 +373,4 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.reviewed").value(true));
}
// ─── PUT .../review-all ───────────────────────────────────────────────────
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
@Test
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
TranscriptionBlock b1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 1").sortOrder(0).reviewed(true).build();
TranscriptionBlock b2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 2").sortOrder(1).reviewed(true).build();
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of(b1, b2));
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].reviewed").value(true))
.andExpect(jsonPath("$[1].reviewed").value(true));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of());
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -1,160 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(RelationshipController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class RelationshipControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean RelationshipService relationshipService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final UUID PERSON_ID = UUID.randomUUID();
private static final UUID OTHER_ID = UUID.randomUUID();
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getRelationshipBetween_returns404_with_RELATIONSHIP_NOT_FOUND_code_when_no_path() throws Exception {
when(relationshipService.getRelationshipBetween(any(), any())).thenReturn(Optional.empty());
mockMvc.perform(get("/api/persons/{aId}/relationship-to/{bId}", PERSON_ID, OTHER_ID))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.RELATIONSHIP_NOT_FOUND.name()));
}
@Test
void getRelationships_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/persons/{id}/relationships", PERSON_ID))
.andExpect(status().isUnauthorized());
}
@Test
void getNetwork_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/network"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"familyMember\":true}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true);
RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", 1900, 1980,
"Bob Müller", 1930, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
mockMvc.perform(get("/api/network"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nodes[0].displayName").value("Alice Müller"))
.andExpect(jsonPath("$.edges[0].relationType").value("PARENT_OF"));
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true);
InferredRelationshipWithPersonDTO inferred =
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
when(relationshipService.getInferredRelationships(PERSON_ID))
.thenReturn(List.of(inferred));
mockMvc.perform(get("/api/persons/{id}/inferred-relationships", PERSON_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].label").value("Großvater"))
.andExpect(jsonPath("$[0].hops").value(2));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns201_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
RelationshipDTO created = new RelationshipDTO(
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", null, null,
"Bob Müller", null, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.relationType").value("PARENT_OF"));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void deleteRelationship_returns204_for_WRITE_ALL_user() throws Exception {
UUID relId = UUID.randomUUID();
doNothing().when(relationshipService).deleteRelationship(any(), any());
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
.andExpect(status().isNoContent());
}
}

View File

@@ -1,353 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.service.PersonService;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static java.util.Collections.emptyList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
import static org.raddatz.familienarchiv.relationship.RelationToken.*;
import static org.raddatz.familienarchiv.relationship.RelationType.*;
/**
* Felix Brandt — TDD red phase for RelationshipInferenceService.
* <p>
* 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires
* a small graph through the mocked repository and asserts the exact abstract
* token sequence emitted by BFS — except {@code distant_label_for_long_chain}
* which asserts the fallback label, and {@code returns_empty_when_no_path}
* which asserts no result.
*/
@ExtendWith(MockitoExtension.class)
class RelationshipInferenceServiceTest {
@Mock PersonRelationshipRepository relationshipRepository;
@Mock PersonService personService;
@InjectMocks RelationshipInferenceService service;
// --- 1: parent ---
@Test
void parent_path_emits_UP() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
assertThat(service.findShortestPath(child.getId(), parent.getId()))
.hasValue(List.of(UP));
}
// --- 2: child ---
@Test
void child_path_emits_DOWN() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
assertThat(service.findShortestPath(parent.getId(), child.getId()))
.hasValue(List.of(DOWN));
}
// --- 3: spouse ---
@Test
void spouse_path_emits_SPOUSE() {
Person a = person();
Person b = person();
givenEdges(spouseOf(a, b));
assertThat(service.findShortestPath(a.getId(), b.getId()))
.hasValue(List.of(SPOUSE));
}
// --- 4: sibling ---
@Test
void sibling_path_emits_SIBLING() {
Person a = person();
Person b = person();
givenEdges(siblingOf(a, b));
assertThat(service.findShortestPath(a.getId(), b.getId()))
.hasValue(List.of(SIBLING));
}
// --- 5: grandparent (UP, UP) ---
@Test
void grandparent_path_emits_UP_UP() {
Person grandparent = person();
Person parent = person();
Person grandchild = person();
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId()))
.hasValue(List.of(UP, UP));
}
// --- 6: grandchild (DOWN, DOWN) ---
@Test
void grandchild_path_emits_DOWN_DOWN() {
Person grandparent = person();
Person parent = person();
Person grandchild = person();
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId()))
.hasValue(List.of(DOWN, DOWN));
}
// --- 7: great-grandparent (UP, UP, UP) ---
@Test
void great_grandparent_path_emits_UP_UP_UP() {
Person g = person();
Person p = person();
Person c = person();
Person gc = person();
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
assertThat(service.findShortestPath(gc.getId(), g.getId()))
.hasValue(List.of(UP, UP, UP));
}
// --- 8: great-grandchild (DOWN, DOWN, DOWN) ---
@Test
void great_grandchild_path_emits_DOWN_DOWN_DOWN() {
Person g = person();
Person p = person();
Person c = person();
Person gc = person();
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
assertThat(service.findShortestPath(g.getId(), gc.getId()))
.hasValue(List.of(DOWN, DOWN, DOWN));
}
// --- 9: uncle/aunt (UP, SIBLING) ---
@Test
void uncle_aunt_path_emits_UP_SIBLING() {
Person grandparent = person();
Person parent = person();
Person uncle = person();
Person me = person();
// grandparent has two children: parent and uncle. me is parent's child.
givenEdges(
parentOf(grandparent, parent),
parentOf(grandparent, uncle),
parentOf(parent, me));
assertThat(service.findShortestPath(me.getId(), uncle.getId()))
.hasValue(List.of(UP, SIBLING));
}
// --- 10: niece/nephew (SIBLING, DOWN) ---
@Test
void niece_nephew_path_emits_SIBLING_DOWN() {
Person grandparent = person();
Person uncle = person();
Person sibling = person();
Person niece = person();
// grandparent has uncle + sibling; sibling has niece.
givenEdges(
parentOf(grandparent, uncle),
parentOf(grandparent, sibling),
parentOf(sibling, niece));
assertThat(service.findShortestPath(uncle.getId(), niece.getId()))
.hasValue(List.of(SIBLING, DOWN));
}
// --- 11: great uncle/aunt (UP, UP, SIBLING) ---
@Test
void great_uncle_aunt_path_emits_UP_UP_SIBLING() {
Person ggp = person();
Person grandparent = person();
Person greatUncle = person();
Person parent = person();
Person me = person();
givenEdges(
parentOf(ggp, grandparent),
parentOf(ggp, greatUncle),
parentOf(grandparent, parent),
parentOf(parent, me));
assertThat(service.findShortestPath(me.getId(), greatUncle.getId()))
.hasValue(List.of(UP, UP, SIBLING));
}
// --- 12: great niece/nephew (SIBLING, DOWN, DOWN) ---
@Test
void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() {
Person grandparent = person();
Person sibling = person();
Person greatUncle = person();
Person niece = person();
Person greatNiece = person();
givenEdges(
parentOf(grandparent, sibling),
parentOf(grandparent, greatUncle),
parentOf(sibling, niece),
parentOf(niece, greatNiece));
assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId()))
.hasValue(List.of(SIBLING, DOWN, DOWN));
}
// --- 13: parent-in-law (SPOUSE, UP) ---
@Test
void inlaw_parent_path_emits_SPOUSE_UP() {
Person inlaw = person();
Person spouse = person();
Person me = person();
givenEdges(
parentOf(inlaw, spouse),
spouseOf(me, spouse));
assertThat(service.findShortestPath(me.getId(), inlaw.getId()))
.hasValue(List.of(SPOUSE, UP));
}
// --- 14: child-in-law (DOWN, SPOUSE) ---
@Test
void inlaw_child_path_emits_DOWN_SPOUSE() {
Person me = person();
Person child = person();
Person inlawChild = person();
givenEdges(
parentOf(me, child),
spouseOf(child, inlawChild));
assertThat(service.findShortestPath(me.getId(), inlawChild.getId()))
.hasValue(List.of(DOWN, SPOUSE));
}
// --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) ---
@Test
void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() {
Person me = person();
Person spouse = person();
Person spouseSibling = person();
givenEdges(
spouseOf(me, spouse),
siblingOf(spouse, spouseSibling));
assertThat(service.findShortestPath(me.getId(), spouseSibling.getId()))
.hasValue(List.of(SPOUSE, SIBLING));
}
// --- 16: cousin (UP, SIBLING, DOWN) ---
@Test
void cousin_1_path_emits_UP_SIBLING_DOWN() {
Person ggp = person();
Person parentMine = person();
Person uncle = person();
Person me = person();
Person cousin = person();
givenEdges(
parentOf(ggp, parentMine),
parentOf(ggp, uncle),
parentOf(parentMine, me),
parentOf(uncle, cousin));
assertThat(service.findShortestPath(me.getId(), cousin.getId()))
.hasValue(List.of(UP, SIBLING, DOWN));
}
// --- 17: distant (label fallback for long chains) ---
@Test
void distant_label_for_long_chain() {
// Seven-generation ancestor: chain of seven PARENT_OF edges.
Person a0 = person();
Person a1 = person();
Person a2 = person();
Person a3 = person();
Person a4 = person();
Person a5 = person();
Person a6 = person();
Person a7 = person();
givenEdges(
parentOf(a0, a1),
parentOf(a1, a2),
parentOf(a2, a3),
parentOf(a3, a4),
parentOf(a4, a5),
parentOf(a5, a6),
parentOf(a6, a7));
Optional<InferredRelationshipDTO> inferred = service.infer(a7.getId(), a0.getId());
assertThat(inferred).hasValueSatisfying(r -> {
assertThat(r.hops()).isEqualTo(7);
assertThat(r.labelFromA()).isEqualTo("distant");
assertThat(r.labelFromB()).isEqualTo("distant");
});
}
// --- 18: no path ---
@Test
void returns_empty_when_no_path() {
Person a = person();
Person b = person();
// No edges between them.
givenEdges(/* none */);
assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty();
assertThat(service.infer(a.getId(), b.getId())).isEmpty();
}
// --- 19: findAllFor delegates person resolution to PersonService ---
@Test
void findAllFor_resolves_persons_via_PersonService() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
when(personService.getAllById(anyList())).thenReturn(List.of(child));
List<InferredRelationshipWithPersonDTO> results = service.findAllFor(parent.getId());
assertThat(results).hasSize(1);
assertThat(results.get(0).person().displayName()).isEqualTo(child.getDisplayName());
}
// --- helpers ---
private void givenEdges(PersonRelationship... edges) {
when(relationshipRepository.findAllByRelationTypeIn(anyCollection()))
.thenReturn(edges.length == 0 ? emptyList() : List.of(edges));
}
private static Person person() {
return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build();
}
private static PersonRelationship parentOf(Person parent, Person child) {
return edge(parent, child, PARENT_OF);
}
private static PersonRelationship spouseOf(Person a, Person b) {
return edge(a, b, SPOUSE_OF);
}
private static PersonRelationship siblingOf(Person a, Person b) {
return edge(a, b, SIBLING_OF);
}
private static PersonRelationship edge(Person a, Person b, RelationType type) {
return PersonRelationship.builder()
.id(UUID.randomUUID())
.person(a)
.relatedPerson(b)
.relationType(type)
.createdAt(Instant.now())
.build();
}
}

View File

@@ -1,181 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Sara blocker 1 — service+DB integration over the family-network constraints.
* Hits the real Postgres so unique_rel, ON DELETE CASCADE, and the partial
* sibling index actually fire.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({
PostgresContainerConfig.class,
FlywayConfig.class,
RelationshipService.class,
RelationshipInferenceService.class,
PersonService.class
})
class RelationshipServiceIntegrationTest {
@Autowired RelationshipService relationshipService;
@Autowired PersonRepository personRepository;
@Autowired PersonRelationshipRepository relationshipRepository;
// PersonService → PersonNameAliasRepository; @DataJpaTest auto-loads it.
@Autowired PersonNameAliasRepository aliasRepository;
@Autowired EntityManager entityManager;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
relationshipRepository.deleteAll();
aliasRepository.deleteAll();
personRepository.deleteAll();
alice = personRepository.save(Person.builder().firstName("Alice").lastName("Müller").familyMember(true).build());
bob = personRepository.save(Person.builder().firstName("Bob").lastName("Müller").familyMember(true).build());
charlie = personRepository.save(Person.builder().firstName("Charlie").lastName("Schmidt").familyMember(false).build());
}
@Test
void addRelationship_stores_and_is_readable() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
assertThat(created.id()).isNotNull();
assertThat(created.personId()).isEqualTo(alice.getId());
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
assertThat(rels).hasSize(1);
assertThat(rels.get(0).relationType()).isEqualTo(RelationType.PARENT_OF);
}
@Test
void addRelationship_throws_409_when_duplicate() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
relationshipService.addRelationship(alice.getId(), dto);
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_409_when_circular_parent() {
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
}
@Test
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
// Charlie is unrelated to this row.
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
// The row is still there.
assertThat(relationshipRepository.findById(created.id())).isPresent();
}
@Test
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
// alice SPOUSE_OF bob. Bob deletes from his side.
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
relationshipService.deleteRelationship(bob.getId(), created.id());
assertThat(relationshipRepository.findById(created.id())).isEmpty();
}
@Test
void setFamilyMember_true_makes_person_appear_in_network() {
// charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie
// so the edge exists, then flip charlie's flag and verify he appears in nodes.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
NetworkDTO before = relationshipService.getFamilyNetwork();
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
relationshipService.setFamilyMember(charlie.getId(), true);
NetworkDTO after = relationshipService.getFamilyNetwork();
assertThat(after.nodes()).extracting("id").contains(charlie.getId());
assertThat(after.edges())
.anyMatch(e -> e.personId().equals(alice.getId()) && e.relatedPersonId().equals(charlie.getId()));
}
@Test
void delete_person_cascades_to_relationships() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
UUID relId = created.id();
assertThat(relationshipRepository.findById(relId)).isPresent();
// Detach managed entities so deleteById's cascade isn't fought by the
// persistence context (the rel row still references bob in memory).
entityManager.flush();
entityManager.clear();
// Delete bob (the relatedPerson) — DB CASCADE must remove the row.
personRepository.deleteById(bob.getId());
personRepository.flush();
assertThat(relationshipRepository.findById(relId)).isEmpty();
}
}

View File

@@ -1,209 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Felix Brandt — TDD red for RelationshipService domain rules.
*
* <p>Required by the plan (Nora blockers 1 + 2):
* <ul>
* <li>{@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}</li>
* <li>{@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}</li>
* </ul>
* Plus: duplicate constraint, self-relationship, year-range, happy-path persistence,
* and ownership permitted from either side.
*/
@ExtendWith(MockitoExtension.class)
class RelationshipServiceTest {
@Mock PersonRelationshipRepository relationshipRepository;
@Mock PersonService personService;
@Mock RelationshipInferenceService inferenceService;
@InjectMocks RelationshipService service;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
alice = person("Alice");
bob = person("Bob");
charlie = person("Charlie");
}
// --- Nora blocker 1 ---
@Test
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
verify(relationshipRepository, never()).delete(any());
}
// --- Nora blocker 2 ---
@Test
void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
// alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice.
when(personService.getById(bob.getId())).thenReturn(bob);
when(personService.getById(alice.getId())).thenReturn(alice);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_DUPLICATE_when_db_constraint_violated() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_persists_with_storage_truth() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
PersonRelationship r = inv.getArgument(0);
r.setId(UUID.randomUUID());
r.setCreatedAt(Instant.now());
return r;
});
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
var result = service.addRelationship(alice.getId(), dto);
assertThat(result.personId()).isEqualTo(alice.getId());
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
assertThat(result.fromYear()).isEqualTo(1900);
assertThat(result.notes()).isEqualTo("first born");
}
@Test
void deleteRelationship_succeeds_when_viewpoint_is_object() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
// Bob is the storage related_person; deleting from his viewpoint should work.
service.deleteRelationship(bob.getId(), relId);
verify(relationshipRepository).delete(rel);
}
@Test
void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() {
UUID relId = UUID.randomUUID();
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
}
@Test
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
// Two edges exist: alice→bob (both family) and alice→charlie (one non-family).
// Only the alice→bob edge must appear in the returned NetworkDTO.
UUID aliceBobRelId = UUID.randomUUID();
UUID aliceCharlieRelId = UUID.randomUUID();
PersonRelationship aliceBob = parentOf(alice, bob, aliceBobRelId);
PersonRelationship aliceCharlie = parentOf(alice, charlie, aliceCharlieRelId);
when(personService.findAllFamilyMembers()).thenReturn(List.of(alice, bob));
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of(aliceBob, aliceCharlie));
NetworkDTO result = service.getFamilyNetwork();
assertThat(result.nodes()).hasSize(2);
assertThat(result.edges()).hasSize(1);
assertThat(result.edges().get(0).personId()).isEqualTo(alice.getId());
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
}
// --- helpers ---
private static Person person(String name) {
return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build();
}
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
return PersonRelationship.builder()
.id(id)
.person(parent)
.relatedPerson(child)
.relationType(RelationType.PARENT_OF)
.createdAt(Instant.now())
.build();
}
}

View File

@@ -5,14 +5,13 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.*;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -25,7 +24,6 @@ class TrainingBlockQueryTest {
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired DocumentRepository documentRepository;
@Autowired AnnotationRepository annotationRepository;
@Autowired PersonRepository personRepository;
private UUID kurrentDocId;
private UUID typewriterDocId;
@@ -38,7 +36,7 @@ class TrainingBlockQueryTest {
.title("Kurrent Brief")
.originalFilename("kurrent.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(kurrentLabels())
.trainingLabels(new java.util.HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
kurrentDocId = kurrentDoc.getId();
@@ -113,115 +111,8 @@ class TrainingBlockQueryTest {
assertThat(result).hasSize(2);
}
// ─── sender-based queries ─────────────────────────────────────────────────
@Test
void findManualKurrentBlocksByPerson_includesBlockFromKurrentLabelledDocument() {
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
Document doc = documentRepository.save(Document.builder()
.title("Brief von Karl")
.originalFilename("karl.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.trainingLabels(kurrentLabels())
.build());
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
assertThat(result).hasSize(1);
}
@Test
void findManualKurrentBlocksByPerson_excludesDocumentWithoutKurrentLabel() {
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
Document doc = documentRepository.save(Document.builder()
.title("Brief von Karl")
.originalFilename("karl.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.build());
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
assertThat(result).isEmpty();
}
@Test
void findManualKurrentBlocksByPerson_excludesOcrBlocks() {
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
Document doc = documentRepository.save(Document.builder()
.title("Brief von Karl")
.originalFilename("karl.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.trainingLabels(kurrentLabels())
.build());
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
blockRepository.save(block(doc.getId(), annId, BlockSource.OCR, false));
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
assertThat(result).isEmpty();
}
@Test
void findManualKurrentBlocksByPerson_excludesOtherSender() {
Person karl = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Test").build());
Document doc = documentRepository.save(Document.builder()
.title("Brief von Karl")
.originalFilename("karl.pdf")
.status(DocumentStatus.UPLOADED)
.sender(karl)
.trainingLabels(kurrentLabels())
.build());
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(anna.getId());
assertThat(result).isEmpty();
}
@Test
void countManualKurrentBlocksByPerson_matchesFindResult() {
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
Document doc = documentRepository.save(Document.builder()
.title("Brief von Karl")
.originalFilename("karl.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.trainingLabels(kurrentLabels())
.build());
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, true));
long count = blockRepository.countManualKurrentBlocksByPerson(sender.getId());
List<TranscriptionBlock> found = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
assertThat(count).isEqualTo(found.size());
}
@Test
void countManualKurrentBlocksByPerson_returnsZeroWhenNoBlocksMatch() {
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
long count = blockRepository.countManualKurrentBlocksByPerson(sender.getId());
assertThat(count).isZero();
}
// ─── helpers ─────────────────────────────────────────────────────────────
private static Set<TrainingLabel> kurrentLabels() {
return new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION));
}
private DocumentAnnotation annotation(UUID docId) {
return DocumentAnnotation.builder()
.documentId(docId)

View File

@@ -1,127 +0,0 @@
package org.raddatz.familienarchiv.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TranscriptionBlockMentionsRepositoryTest {
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired DocumentRepository documentRepository;
@Autowired AnnotationRepository annotationRepository;
@Autowired EntityManager em;
private UUID documentId;
private UUID annotationId;
@BeforeEach
void setUp() {
Document doc = documentRepository.save(Document.builder()
.title("Letter")
.originalFilename("letter.pdf")
.status(DocumentStatus.UPLOADED)
.build());
documentId = doc.getId();
DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(1)
.x(0.1).y(0.2).width(0.3).height(0.4)
.color("#00C7B1")
.build());
annotationId = annotation.getId();
}
@Test
void mentionedPersons_roundTripsTwoEntries() {
UUID auguste = UUID.randomUUID();
UUID hermann = UUID.randomUUID();
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId)
.documentId(documentId)
.text("Liebe Tante @Auguste Raddatz, Onkel @Hermann Müller schreibt …")
.sortOrder(0)
.mentionedPersons(List.of(
new PersonMention(auguste, "Auguste Raddatz"),
new PersonMention(hermann, "Hermann Müller")
))
.build());
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getMentionedPersons())
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
.containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple(auguste, "Auguste Raddatz"),
org.assertj.core.groups.Tuple.tuple(hermann, "Hermann Müller"));
}
@Test
void mentionedPersons_defaultsToEmptyList_whenNotSet() {
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId)
.documentId(documentId)
.text("Plain text without mentions")
.sortOrder(0)
.build());
em.clear();
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getMentionedPersons()).isEmpty();
}
@Test
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
UUID augusteId = UUID.randomUUID();
UUID hermannId = UUID.randomUUID();
blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId).documentId(documentId)
.text("Brief von @Auguste Raddatz an @Hermann Müller.")
.sortOrder(0)
.mentionedPersons(List.of(
new PersonMention(augusteId, "Auguste Raddatz"),
new PersonMention(hermannId, "Hermann Müller")))
.build());
blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId).documentId(documentId)
.text("Unrelated block without Auguste.")
.sortOrder(1)
.mentionedPersons(List.of(new PersonMention(hermannId, "Hermann Müller")))
.build());
em.clear();
List<TranscriptionBlock> result =
blockRepository.findByPersonIdWithMentionsFetched(augusteId);
assertThat(result).hasSize(1);
assertThat(result.get(0).getMentionedPersons())
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
.containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple(augusteId, "Auguste Raddatz"),
org.assertj.core.groups.Tuple.tuple(hermannId, "Hermann Müller"));
}
}

View File

@@ -1,178 +0,0 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.GeschichteRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class GeschichteServiceIntegrationTest {
@MockitoBean
S3Client s3Client;
@Autowired GeschichteService geschichteService;
@Autowired GeschichteRepository geschichteRepository;
@Autowired PersonRepository personRepository;
@Autowired AppUserRepository appUserRepository;
AppUser writer;
AppUser reader;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("writer-int@test")
.password("hash")
.build());
reader = appUserRepository.save(AppUser.builder()
.email("reader-int@test")
.password("hash")
.build());
}
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
@Test
void create_then_publish_then_read_then_delete_full_lifecycle() {
// Create as writer
authenticateAs(writer, Permission.BLOG_WRITE);
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("Raddatz").build());
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Erinnerung an Opa Franz");
dto.setBody("<p>Ich erinnere mich, wie er <strong>jeden Sonntag</strong> sang.</p>"
+ "<script>alert('xss')</script>");
dto.setPersonIds(List.of(franz.getId()));
Geschichte created = geschichteService.create(dto);
assertThat(created.getId()).isNotNull();
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.getBody())
.contains("<strong>jeden Sonntag</strong>")
.doesNotContain("<script>");
// Reader cannot see DRAFT in list
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.getId();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
.hasMessageContaining("not found");
// Publish as writer
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
publishDto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.getPublishedAt()).isNotNull();
// Reader can now see and fetch it
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
Geschichte fetched = geschichteService.getById(draftId);
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
// Delete as writer; join rows go with it
authenticateAs(writer, Permission.BLOG_WRITE);
geschichteService.delete(draftId);
assertThat(geschichteRepository.findById(draftId)).isEmpty();
// The Person itself is untouched (cascade only flows from Geschichte to join table)
assertThat(personRepository.findById(franz.getId())).isPresent();
}
@Test
void list_filters_with_AND_semantics_when_multiple_personIds_given() {
// Three published stories, persons overlap so we can prove AND-not-OR:
// story_AB: about A and B
// story_AC: about A and C
// story_A: about A only
authenticateAs(writer, Permission.BLOG_WRITE);
Person a = personRepository.save(Person.builder().firstName("Anna").lastName("A").build());
Person b = personRepository.save(Person.builder().firstName("Bertha").lastName("B").build());
Person c = personRepository.save(Person.builder().firstName("Carl").lastName("C").build());
UUID storyAB = publishedStoryWithPersons("Anna & Bertha", List.of(a.getId(), b.getId()));
UUID storyAC = publishedStoryWithPersons("Anna & Carl", List.of(a.getId(), c.getId()));
UUID storyA = publishedStoryWithPersons("Anna alone", List.of(a.getId()));
authenticateAs(reader, Permission.READ_ALL);
// No filter → all three
assertThat(geschichteService.list(null, List.of(), null, 50))
.extracting(Geschichte::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// Single filter (Anna) → all three
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
.extracting(Geschichte::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
.extracting(Geschichte::getId)
.containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both)
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50))
.isEmpty();
// AND: Anna AND Bertha AND Carl → none
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50))
.isEmpty();
}
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle(title);
dto.setBody("<p>body</p>");
dto.setPersonIds(personIds);
dto.setStatus(GeschichteStatus.PUBLISHED);
return geschichteService.create(dto).getId();
}
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = java.util.Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.name()))
.toList();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
}
}

View File

@@ -1,441 +0,0 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.GeschichteRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GeschichteServiceTest {
@Mock
GeschichteRepository geschichteRepository;
@Mock
PersonService personService;
@Mock
DocumentService documentService;
@Mock
UserService userService;
@InjectMocks
GeschichteService geschichteService;
AppUser writer;
AppUser reader;
@BeforeEach
void setUp() {
SecurityContextHolder.clearContext();
writer = AppUser.builder().id(UUID.randomUUID()).email("writer@test").build();
reader = AppUser.builder().id(UUID.randomUUID()).email("reader@test").build();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// ─── getById ──────────────────────────────────────────────────────────────
@Test
void getById_throws_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
Geschichte draft = draft(id);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft));
assertThatThrownBy(() -> geschichteService.getById(id))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
@Test
void getById_returns_draft_when_user_has_BLOG_WRITE() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte draft = draft(id);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft));
Geschichte result = geschichteService.getById(id);
assertThat(result).isSameAs(draft);
}
@Test
void getById_returns_published_to_anyone_authenticated() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
Geschichte published = published(id);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
Geschichte result = geschichteService.getById(id);
assertThat(result).isSameAs(published);
}
@Test
void getById_throws_NOT_FOUND_when_id_unknown() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> geschichteService.getById(id))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
// ─── list ─────────────────────────────────────────────────────────────────
@Test
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
// Status pinning lives inside the Specification; we assert end-to-end behaviour
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
// through the spec-aware repository method.
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
assertThat(out).hasSize(2);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
authenticateAs(reader, Permission.READ_ALL);
UUID personId = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, List.of(personId), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
authenticateAs(reader, Permission.READ_ALL);
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, List.of(a, b), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_filters_by_documentId() {
authenticateAs(reader, Permission.READ_ALL);
UUID documentId = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, List.of(), documentId, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
// ─── create ──────────────────────────────────────────────────────────────
@Test
void create_sets_status_to_DRAFT_by_default() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("My Story");
dto.setBody("<p>plain text</p>");
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
assertThat(saved.getAuthor()).isSameAs(writer);
}
@Test
void create_sanitizes_body_HTML_dropping_disallowed_tags() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("XSS attempt");
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getBody())
.contains("<p>safe</p>")
.doesNotContain("<script>")
.doesNotContain("onerror")
.doesNotContain("<img");
}
@Test
void create_keeps_allowed_tags_strong_em_h2_h3_ul_ol_li() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Rich");
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getBody())
.contains("<h2>Heading</h2>")
.contains("<strong>bold</strong>")
.contains("<em>italic</em>")
.contains("<ul>")
.contains("<ol>")
.contains("<li>one</li>");
}
@Test
void create_resolves_personIds_via_PersonService() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
UUID personId = UUID.randomUUID();
Person person = Person.builder().id(personId).build();
when(personService.getAllById(List.of(personId))).thenReturn(List.of(person));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Linked");
dto.setPersonIds(List.of(personId));
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getPersons()).containsExactly(person);
}
@Test
void create_resolves_documentIds_via_DocumentService() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).build();
when(documentService.getDocumentById(docId)).thenReturn(doc);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Linked doc");
dto.setDocumentIds(List.of(docId));
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getDocuments()).containsExactly(doc);
}
@Test
void create_throws_BAD_REQUEST_when_title_blank() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle(" ");
dto.setBody("<p>x</p>");
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
}
// ─── update ──────────────────────────────────────────────────────────────
@Test
void update_sets_publishedAt_when_status_transitions_to_PUBLISHED() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setPublishedAt(null);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.getPublishedAt()).isNotNull();
}
@Test
void update_clears_publishedAt_when_status_transitions_back_to_DRAFT() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = published(id);
existing.setPublishedAt(LocalDateTime.now().minusDays(1));
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.DRAFT);
Geschichte saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
}
@Test
void update_sanitizes_body_on_save() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
Geschichte saved = geschichteService.update(id, dto);
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
}
@Test
void update_throws_NOT_FOUND_when_geschichte_unknown() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> geschichteService.update(id, new GeschichteUpdateDTO()))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
// ─── delete ──────────────────────────────────────────────────────────────
@Test
void delete_calls_repository_deleteById() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(true);
geschichteService.delete(id);
verify(geschichteRepository).deleteById(id);
}
@Test
void delete_throws_NOT_FOUND_when_unknown() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(false);
assertThatThrownBy(() -> geschichteService.delete(id))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
verify(geschichteRepository, never()).deleteById(any());
}
// ─── helpers ─────────────────────────────────────────────────────────────
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = List.of(permissions).stream()
.map(p -> new SimpleGrantedAuthority(p.name()))
.collect(Collectors.toList());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
}
private Geschichte draft(UUID id) {
return Geschichte.builder()
.id(id)
.title("Draft")
.body("<p>body</p>")
.status(GeschichteStatus.DRAFT)
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
private Geschichte published(UUID id) {
return Geschichte.builder()
.id(id)
.title("Published")
.body("<p>body</p>")
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now().minusHours(1))
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
}

View File

@@ -24,10 +24,7 @@ import java.util.UUID;
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.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PersonServiceTest {

View File

@@ -17,7 +17,6 @@ import org.raddatz.familienarchiv.model.BlockSource;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.ScriptType;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
@@ -99,9 +98,7 @@ class TranscriptionServiceTest {
return b;
});
CreateTranscriptionBlockDTO dto = CreateTranscriptionBlockDTO.builder()
.pageNumber(1).x(0.1).y(0.2).width(0.3).height(0.4)
.text("hello").build();
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
@@ -171,7 +168,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("new text").build();
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
@@ -192,7 +189,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("text").label("Anrede").build();
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
@@ -211,65 +208,11 @@ class TranscriptionServiceTest {
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
TranscriptionBlock result = transcriptionService.updateBlock(
docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new").build(), UUID.randomUUID());
docId, blockId, new UpdateTranscriptionBlockDTO("new", null), UUID.randomUUID());
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
}
@Test
void updateBlock_replacesMentionedPersonsFromDto() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
UUID personId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(blockId).documentId(docId).text("old").build();
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
PersonMention mention = new PersonMention(personId, "Auguste");
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder()
.text("@Auguste text")
.mentionedPersons(List.of(mention))
.build();
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
assertThat(result.getMentionedPersons())
.containsExactly(mention);
}
@Test
void updateBlock_clearsPriorMentions_beforeApplyingDto() {
UUID docId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
PersonMention prior = new PersonMention(UUID.randomUUID(), "Heinrich");
PersonMention incoming = new PersonMention(UUID.randomUUID(), "Auguste");
TranscriptionBlock block = TranscriptionBlock.builder()
.id(blockId).documentId(docId).text("old").build();
block.getMentionedPersons().add(prior);
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder()
.text("@Auguste text")
.mentionedPersons(List.of(incoming))
.build();
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
assertThat(result.getMentionedPersons())
.containsExactly(incoming)
.doesNotContain(prior);
}
@Test
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
UUID docId = UUID.randomUUID();
@@ -283,7 +226,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
verify(senderModelService).checkAndTriggerTraining(senderId);
}
@@ -299,7 +242,7 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
verify(senderModelService, never()).checkAndTriggerTraining(any());
}
@@ -534,7 +477,7 @@ class TranscriptionServiceTest {
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId);
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null), userId);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
@@ -559,90 +502,8 @@ class TranscriptionServiceTest {
when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("same text").build(), userId);
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("same text", null), userId);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
@Test
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(List.of(block1, block2));
}
@Test
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(any());
}
@Test
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(alreadyReviewed, unreviewed));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).isEmpty();
}
}

View File

@@ -1,55 +0,0 @@
# ADR-006: Synchronous domain events inside the publisher's transaction
## Status
Accepted
## Context
Issue #362 introduced the first cross-domain side-effect in this codebase: when a Person's display name changes, every transcription block that mentions the person must be rewritten — both `block.text` (the literal `@OldName` substring) and the `mentionedPersons` sidecar (the `displayName` field on the matching `PersonMention`). The rewrite is bidirectionally referential — Person depends on Transcription to make the rename atomic, and Transcription depends on Person to know what the new display name is.
A direct method call from `PersonService` into `TranscriptionBlockService` would invert the existing dependency arrow (Document → Person, not Person → Transcription) and introduce a runtime-circular reference at the package level. Avoiding the cycle while keeping the rename atomic is the constraint this ADR addresses.
Two prior pieces of infrastructure constrain the solution:
- `transcription_blocks.version` (JPA `@Version`) — concurrent autosave on a referenced block must roll back the rename instead of silently overwriting the autosave.
- `OcrTrainingService.recoverOrphanedRuns` is the only existing `@EventListener` and it consumes Spring's built-in `ApplicationReadyEvent` — no precedent for a custom domain event in this codebase before now.
## Decision
`PersonService.updatePerson` publishes `PersonDisplayNameChangedEvent(personId, oldDisplayName, newDisplayName)` via `ApplicationEventPublisher` whenever `Person.getDisplayName()` flips between the pre-save snapshot and the post-save value. `PersonMentionPropagationListener` (in the transcription package's `service/` layer) handles the event with `@EventListener @Transactional`, finds blocks via `findByMentionedPersons_PersonId`, rewrites text + sidecar, and calls `saveAllAndFlush`.
**Synchronous on purpose.** Spring's default event dispatcher invokes listeners on the publishing thread, inside the publisher's transaction. The propagation runs as part of the same `@Transactional` boundary as the rename — `OptimisticLockingFailureException` from a referenced block bubbles back up, the surrounding transaction rolls back, and `PersonService.updatePerson` translates it to `DomainException(PERSON_RENAME_CONFLICT, 409)`.
**Pattern for future cross-domain decoupling:**
1. Event record in `model/` of the publishing domain (e.g. `PersonDisplayNameChangedEvent`).
2. Listener in `service/` of the consuming domain (e.g. `PersonMentionPropagationListener`).
3. `@EventListener @Transactional` on the listener method — no `@TransactionalEventListener` unless the work genuinely doesn't need to commit with the publisher.
4. `saveAllAndFlush` (not `saveAll`) on any write where exceptions must surface inside the listener call so the publisher can catch and translate them — `saveAll` defers exceptions to commit time, after the publisher's `try` block has exited.
5. Audit log line at `INFO` level on the listener method — historical-text mutation needs an audit trail.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| `PersonService` calls `TranscriptionBlockService.propagateDisplayNameChange(...)` directly | Inverts the dependency arrow. Person becomes runtime-coupled to Transcription; future domains that also care about renames (Comments, Notifications) compound the coupling. Events keep Person agnostic of who consumes them. |
| `@TransactionalEventListener(AFTER_COMMIT) + @Async` | The propagation would run after the rename commits, on a separate transaction. A failed propagation could leave block text out of sync with the renamed person until manual repair. Atomic transactional coupling is the safer default for historical-text mutation; switch to async only when the block count makes sync latency unacceptable (rough threshold: tens of thousands of blocks per renamed person). |
| Database trigger on `persons.last_name` | PL/pgSQL trigger would have to reach into `transcription_block_mentioned_persons` and `transcription_blocks.text`, smearing domain logic across SQL and Java. JPA's `@Version` would also be invisible to the trigger, so concurrent block autosaves would race silently. |
| Hibernate entity listener (`@PostUpdate` on Person) | Couples to Hibernate internals; harder to test in isolation; mixes lifecycle hooks with cross-domain side effects. Spring's `ApplicationEventPublisher` keeps the integration declarative and unit-testable. |
## Consequences
**Easier:**
- Person domain stays free of any compile-time dependency on Transcription. Future consumers (Comments, Notifications) subscribe to the same event without `PersonService` knowing they exist.
- Rename + propagation share one transaction → no half-applied state visible to readers, no orphaned rewrites if the rename fails after propagation, no "eventually-consistent" window for an archive that prizes historical fidelity.
- Concurrent autosaves on referenced blocks raise a structured 409 the frontend can render meaningfully (`error_person_rename_conflict`) instead of a generic 500.
- The pattern itself (record event in `model/`, listener in consumer's `service/`, sync `@EventListener @Transactional`, `saveAllAndFlush`) is reusable for the next cross-domain side effect.
**Harder:**
- Listener latency adds to the rename request's response time. The 200-block latency floor (< 2 s) is a merge-blocking regression test; if archive growth pushes it up, the migration path is one-annotation: switch to `@TransactionalEventListener(AFTER_COMMIT) + @Async` and add a manual-repair tool for propagation failures.
- Tests for the listener path require routing the publisher mock through a real listener (see `PersonServiceTest#updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock`). Slightly more setup than a pure-Mockito test, but exercises the production call chain.
- `saveAllAndFlush` is mandatory in any synchronous listener that must surface JPA exceptions to the publisher's `try`-block. `saveAll` alone defers the flush to transaction commit, which happens after the publisher returns.
## Future Direction
If a single rename starts touching tens of thousands of blocks, switch the listener to `@TransactionalEventListener(phase = AFTER_COMMIT)` paired with `@Async` and add (a) an idempotency key to the event so a retry doesn't double-rewrite, (b) an admin tool that scans for sidecar entries whose `displayName` doesn't match the current `Person.getDisplayName()` and repairs them. At that point the orphan-guard path (existsById check before the rewrite) re-enters the listener as a deliberate piece of the async machinery rather than dead code.

View File

@@ -1,987 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stammbaum — Document Badge · Inline Pill Variant · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
/* ── Masthead ── */
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:60px}
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
.mh p{font-size:13px;color:#555;max-width:740px;line-height:1.75;margin-top:8px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.tag{background:#012851;color:#A1DCD8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tag.amber{background:#7c4a00;color:#fde68a}
/* ── Section headers ── */
.sh{margin:0 0 28px}
.sh h2{font-size:16px;font-weight:900;color:#012851;letter-spacing:-.2px}
.sh p{font-size:12.5px;color:#666;max-width:720px;line-height:1.7;margin-top:5px}
.section{margin-bottom:80px;padding-bottom:80px;border-bottom:2px dashed #C8C4BE}
.section:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
/* ── Token tables ── */
.token-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
.token-table{border-radius:6px;overflow:hidden}
.token-table.light{background:#fff;border:1px solid #E0DDD6}
.token-table.dark{background:#0F1923;border:1px solid #1E2D3D}
.token-head{padding:8px 14px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;border-bottom:1px solid #E0DDD6}
.token-table.light .token-head{background:#F4F2EC;color:#888;border-bottom-color:#E0DDD6}
.token-table.dark .token-head{background:#0A1218;color:#4E6070;border-bottom-color:#1E2D3D}
.token-table table{width:100%;border-collapse:collapse;font-size:11px}
.token-table.light td{padding:6px 14px;border-bottom:1px solid #F0EEE8;vertical-align:middle}
.token-table.dark td{padding:6px 14px;border-bottom:1px solid #1A2830;vertical-align:middle;color:#8AAABB}
.token-table tr:last-child td{border-bottom:none}
.token-table.light td:first-child{font-size:9px;font-weight:700;color:#888;width:160px}
.token-table.dark td:first-child{font-size:9px;font-weight:700;color:#4E6070;width:160px}
.swatch{display:inline-block;width:12px;height:12px;border-radius:2px;vertical-align:middle;margin-right:6px}
.swatch.bordered{border:1px solid #DDD}
.warn{display:inline-block;background:#FEF3C7;color:#92400E;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
.pass{display:inline-block;background:#D1FAE5;color:#065F46;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
/* ── Browser chrome ── */
.chrome{border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
.chrome.dark{background:#010e1e;border-color:#0d3358}
.chrome-bar{height:20px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 8px;gap:4px;flex-shrink:0}
.chrome.dark .chrome-bar{background:#010a18;border-bottom-color:#0d3358}
.chrome-dot{width:6px;height:6px;border-radius:50%;background:#BDB8B1}
.chrome.dark .chrome-dot{background:#1a2a3a}
.chrome-url{flex:1;height:9px;background:#CCC8C2;border-radius:5px;margin-left:6px}
.chrome.dark .chrome-url{background:#1a2a3a}
/* ── App nav ── */
.app-nav{height:34px;background:#012851;border-top:4px solid #A1DCD8;display:flex;align-items:center;padding:0 12px;gap:10px;flex-shrink:0}
.app-logo{font-family:'Tinos',Georgia,serif;font-size:7px;font-weight:700;color:#fff;letter-spacing:.5px}
.app-link{font-size:5.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.4);white-space:nowrap}
.app-link.on{color:rgba(255,255,255,.9)}
.app-nav-r{margin-left:auto;display:flex;gap:6px;align-items:center}
.app-av{width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5)}
/* ── Sub-header bar ── */
.sub-header{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:6px;flex-shrink:0}
.chrome.dark .sub-header{background:#011526;border-bottom-color:#0d3358}
.back-btn{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
.chrome.dark .back-btn{color:#8b97a5}
.sh-divider{width:1px;height:18px;background:#E4E2D7;flex-shrink:0;margin:0 4px}
.chrome.dark .sh-divider{background:#0d3358}
.sh-doc-title{font-family:'Tinos',Georgia,serif;font-size:10px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.chrome.dark .sh-doc-title{color:#f0efe9}
/* person chips in sub-header */
.sh-persons{display:flex;align-items:center;gap:5px;flex-shrink:0}
.sh-chip{display:flex;align-items:center;gap:4px}
.sh-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
.sh-name{font-size:8px;font-weight:600;color:#4b5563;white-space:nowrap}
.chrome.dark .sh-name{color:#9ca3af}
.sh-arrow{color:#A1DCD8;flex-shrink:0}
.chrome.dark .sh-arrow{color:#00c7b1}
/* INLINE PILL */
.pill{display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;margin-left:5px;vertical-align:middle;line-height:1.5}
.chrome.dark .pill{background:rgba(0,199,177,.10);border-color:#00c7b1;color:#f0efe9}
/* sub-header actions */
.sh-actions{display:flex;align-items:center;gap:5px;flex-shrink:0;margin-left:8px}
.sh-btn-ghost{height:22px;padding:0 7px;border:1.5px solid #E4E2D7;border-radius:3px;font-size:6.5px;font-weight:700;color:#4b5563;display:flex;align-items:center;gap:3px;flex-shrink:0}
.chrome.dark .sh-btn-ghost{border-color:#0d3358;color:#8b97a5}
.sh-btn-primary{height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;gap:3px;flex-shrink:0}
.chrome.dark .sh-btn-primary{background:#A1DCD8;color:#012851}
.sh-btn-icon{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
.chrome.dark .sh-btn-icon{border-color:#0d3358;color:#8b97a5}
/* ── Metadata drawer ── */
.meta-drawer{background:#ffffff;border-bottom:1px solid #E4E2D7;padding:14px 16px;flex-shrink:0}
.chrome.dark .meta-drawer{background:#011526;border-bottom-color:#0d3358}
.meta-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
.meta-col-head{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
.chrome.dark .meta-col-head{color:#8b97a5}
.meta-field{margin-bottom:8px}
.meta-label{font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
.chrome.dark .meta-label{color:#8b97a5}
.meta-value{font-family:'Tinos',Georgia,serif;font-size:10px;color:#012851}
.chrome.dark .meta-value{color:#f0efe9}
/* ── Person card in metadata ── */
.person-card{display:flex;align-items:center;gap:5px;padding:3px 5px;border-radius:3px}
.p-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6.5px;font-weight:800;color:#fff;flex-shrink:0}
.p-name{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851}
.chrome.dark .p-name{color:#f0efe9}
/* ── PDF placeholder ── */
.pdf-area{background:#d4d0c8;flex:1;display:flex;align-items:center;justify-content:center;min-height:80px}
.chrome.dark .pdf-area{background:#010e1e}
.paper{background:#FFFEF8;width:40%;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:8px 10px;display:flex;flex-direction:column;gap:2px}
.chrome.dark .paper{background:#0d1820}
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px}
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px}
.chrome.dark .pl,.chrome.dark .ps{background:#1E2D3D}
/* ── Side-by-side layout ── */
.split-screens{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
.screen-lbl{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:8px;display:flex;align-items:center;gap:5px}
.lbl-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.cap{font-size:10px;color:#999;font-style:italic;line-height:1.6;margin-top:10px;max-width:460px}
/* ── Edge-case cards ── */
.edge-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:12px}
.edge-card{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
.edge-head{background:#F4F2EC;padding:8px 12px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;border-bottom:1px solid #E0DDD6}
.edge-body{padding:10px 12px}
.edge-note{font-size:10.5px;color:#555;line-height:1.65;margin-top:8px}
.no-badge{font-family:'Tinos',Georgia,serif;font-size:9px;color:#aaa;font-style:italic;padding:4px 5px}
/* ── Rules / implementation table ── */
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
.rules table{width:100%;border-collapse:collapse}
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
.rules td{font-size:11px;color:#444;padding:8px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.6}
.rules tr:last-child td{border-bottom:none}
.rules td:first-child{font-size:9px;font-weight:700;color:#012851;white-space:nowrap;width:200px}
.rules td code{font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px;color:#555;white-space:nowrap}
/* ── Pill anatomy callout ── */
.pill-anatomy{display:flex;align-items:center;gap:20px;background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:18px 24px;margin-bottom:16px;flex-wrap:wrap}
.pill-demo-light{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f9f8f4;border-radius:4px}
.pill-demo-dark{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#011526;border-radius:4px}
.pill-annotation{font-size:9.5px;color:#888;line-height:1.7}
.pill-annotation strong{color:#012851;font-weight:700}
/* ── Responsive preview containers ── */
.responsive-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
.responsive-grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:24px;margin-bottom:16px}
/* ── Tablet sub-header ── */
.sub-header-tablet{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0}
.sh-title-truncated{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.sh-overflow-btn{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;font-size:9px;font-weight:700;flex-shrink:0}
.meta-stacked{padding:12px 14px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:9px}
.meta-stacked .meta-label{font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
.meta-stacked .meta-value{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851;margin-bottom:10px}
.meta-stacked .meta-section-head{font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
/* ── Mobile sub-header ── */
.sub-header-mobile{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0}
.sh-title-mobile{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.meta-mobile{padding:10px 12px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:8.5px}
.meta-mobile .m-label{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:2px;margin-top:8px}
.meta-mobile .m-label:first-child{margin-top:0}
.meta-mobile .m-value{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;margin-bottom:2px}
.person-row-mobile{display:flex;align-items:center;gap:4px;flex-wrap:nowrap}
.person-row-mobile .p-av-sm{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
.person-row-mobile .p-nm{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;white-space:nowrap}
</style>
</head>
<body>
<div class="doc">
<!-- ══ MASTHEAD ══════════════════════════════════════════════════════════════ -->
<div class="mh">
<h1>Stammbaum — Document Badge · Inline Pill Variant</h1>
<p>
Design spec for the inline relationship pill on the Document Detail page. Relationship labels appear
as <strong>inline pills directly next to each person's name</strong> — both in the 48 px sub-header bar
and in the Personen column of the 3-column metadata drawer. Example: Karl Raddatz
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">ELTERNTEIL</span>
→ Hans Raddatz
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">KIND</span>.
This is View 2 of 3 in the Stammbaum document-badge feature set.
</p>
<div class="byline">Familienarchiv · 2026-04-27 · Leonie Voss, UX Lead</div>
<div class="tag-row">
<span class="tag">Stammbaum Feature</span>
<span class="tag">View 2 of 3 — Document Badge</span>
<span class="tag">Inline Pill Variant</span>
<span class="tag">Desktop / Tablet / Mobile</span>
<span class="tag">Light + Dark</span>
</div>
</div>
<!-- ══ SECTION 1 — DESIGN TOKENS ════════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>1 · Design tokens</h2>
<p>All colour values used by the inline pill and its surrounding context. Light and dark themes are shown side by side. Contrast ratios are against the respective surface colour.</p>
</div>
<!-- Pill anatomy callout -->
<div class="pill-anatomy">
<div class="pill-demo-light">
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#012851;font-weight:700">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle">ELTERNTEIL</span>
</div>
<div class="pill-demo-dark">
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#f0efe9;font-weight:700">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle">ELTERNTEIL</span>
</div>
<div class="pill-annotation">
<strong>Pill anatomy</strong><br>
border-radius: 9999px &nbsp;·&nbsp; padding: 1px 8px<br>
font: Montserrat 9px 700 uppercase letter-spacing .07em<br>
margin-left: 8px from name span &nbsp;·&nbsp; vertical-align: middle
</div>
</div>
<div class="token-grid">
<!-- Light -->
<div class="token-table light">
<div class="token-head">Light theme — surface #ffffff</div>
<table>
<tr>
<td>Pill bg</td>
<td><span class="swatch bordered" style="background:rgba(161,220,216,.25)"></span>rgba(161,220,216,.25) — near-white on white<span class="pass">~14:1 AAA ✓ (text on near-white)</span></td>
</tr>
<tr>
<td>Pill border</td>
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — mint accent outline</td>
</tr>
<tr>
<td>Pill text</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — navy ink<span class="pass">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Person name</td>
<td><span class="swatch" style="background:#4b5563"></span>#4b5563 — Montserrat 11px (sub-header)</td>
</tr>
<tr>
<td>Meta person name</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 9.5px (metadata drawer)</td>
</tr>
<tr>
<td>Sub-header bg</td>
<td><span class="swatch bordered" style="background:#ffffff"></span>#ffffff</td>
</tr>
<tr>
<td>Sub-header border</td>
<td><span class="swatch" style="background:#e4e2d7"></span>#e4e2d7</td>
</tr>
<tr>
<td>Arrow (decorative)</td>
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — <code>aria-hidden</code><span class="warn">non-text only</span></td>
</tr>
<tr>
<td>Meta label</td>
<td><span class="swatch" style="background:#6b7280"></span>#6b7280 — Montserrat 9px 700 uppercase<span class="pass">4.8:1 AA ✓</span></td>
</tr>
<tr>
<td>Meta value</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 13px<span class="pass">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Doc title</td>
<td>Tinos serif 18px · #012851</td>
</tr>
<tr>
<td>Avatar KR</td>
<td><span class="swatch" style="background:#012851"></span>#012851 — navy</td>
</tr>
<tr>
<td>Avatar HR</td>
<td><span class="swatch" style="background:#5a2d6f"></span>#5a2d6f — purple</td>
</tr>
</table>
</div>
<!-- Dark -->
<div class="token-table dark">
<div class="token-head">Dark theme — surface #011526</div>
<table>
<tr>
<td>Pill bg</td>
<td><span class="swatch bordered" style="background:rgba(0,199,177,.10);border-color:#0d3358"></span>rgba(0,199,177,.10) — dark teal wash<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">passes AA ✓</span></td>
</tr>
<tr>
<td>Pill border</td>
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — turquoise</td>
</tr>
<tr>
<td>Pill text</td>
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — warm white<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Person name</td>
<td><span class="swatch" style="background:#9ca3af"></span>#9ca3af — (sub-header)</td>
</tr>
<tr>
<td>Meta person name</td>
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — (metadata drawer)</td>
</tr>
<tr>
<td>Sub-header bg</td>
<td><span class="swatch" style="background:#011526;border:1px solid #0d3358"></span>#011526</td>
</tr>
<tr>
<td>Sub-header border</td>
<td><span class="swatch" style="background:#0d3358"></span>#0d3358</td>
</tr>
<tr>
<td>Arrow (decorative)</td>
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — <code>aria-hidden</code><span class="warn" style="background:rgba(254,243,199,.1);color:#FDE68A;border:none">non-text only</span></td>
</tr>
<tr>
<td>Meta label</td>
<td><span class="swatch" style="background:#8b97a5"></span>#8b97a5<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">7.1:1 AAA ✓</span></td>
</tr>
<tr>
<td>Meta value</td>
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
</tr>
<tr>
<td>Doc title</td>
<td>Tinos serif 18px · #f0efe9</td>
</tr>
</table>
</div>
</div>
<p style="font-size:10.5px;color:#888;font-style:italic;margin-top:6px">
⚠ Pill background rgba(161,220,216,.25) is nearly transparent on white — the effective contrast for the text is calculated against the near-white composite, yielding ~14:1.
The arrow between sender and receiver chips in the sub-header is <code>aria-hidden="true"</code> — directional meaning is conveyed by DOM order (sender before receiver) and the visual left-to-right reading order.
</p>
</div>
<!-- ══ SECTION 2 — DESKTOP LIGHT & DARK ═════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>2 · Desktop (1280 px) — light &amp; dark</h2>
<p>
Full document detail page at ~65% scale. Sub-header bar (48 px) shows inline pills next to avatar chips.
Metadata drawer is open, showing pills next to person names in the Personen column.
Both light and dark themes shown side by side.
</p>
</div>
<div class="split-screens">
<!-- ── LIGHT ── -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Light theme</div>
<div class="chrome">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<!-- App header -->
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-link">Stammbaum</div>
<div class="app-link">Admin</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<!-- Sub-header -->
<div class="sub-header">
<div class="back-btn">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="sh-divider"></div>
<div class="sh-doc-title">W-0311 · Divacca</div>
<div class="sh-persons">
<!-- Sender chip + pill -->
<div class="sh-chip">
<div class="sh-av" style="background:#012851">KR</div>
<span class="sh-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
<!-- Arrow -->
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
<!-- Receiver chip + pill -->
<div class="sh-chip">
<div class="sh-av" style="background:#5a2d6f">HR</div>
<span class="sh-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
<div class="sh-actions">
<div class="sh-btn-ghost">Details ▾</div>
<div class="sh-btn-ghost">Transkribieren</div>
<div class="sh-btn-primary">Bearbeiten</div>
<div class="sh-btn-icon">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</div>
</div>
</div>
<!-- Metadata drawer -->
<div class="meta-drawer">
<div class="meta-grid">
<!-- Col 1: Details -->
<div>
<div class="meta-col-head">Details</div>
<div class="meta-field">
<div class="meta-label">Datum</div>
<div class="meta-value"></div>
</div>
<div class="meta-field">
<div class="meta-label">Ort</div>
<div class="meta-value">Divacca</div>
</div>
<div class="meta-field">
<div class="meta-label">Status</div>
<div class="meta-value">Hochgeladen</div>
</div>
</div>
<!-- Col 2: Personen with inline pills -->
<div>
<div class="meta-col-head">Personen</div>
<div class="meta-field">
<div class="meta-label">Absender</div>
<div class="person-card">
<div class="p-av" style="background:#012851">KR</div>
<span class="p-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label">Empfänger</div>
<div class="person-card">
<div class="p-av" style="background:#5a2d6f">HR</div>
<span class="p-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
</div>
<!-- Col 3: Tags -->
<div>
<div class="meta-col-head">Schlagwörter</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
</div>
</div>
</div>
</div>
<!-- PDF area -->
<div class="pdf-area">
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
</div>
</div>
<p class="cap">Light. Pills appear in both the sub-header chip row and the metadata Personen column. Arrow between chips is mint-coloured and aria-hidden.</p>
</div>
<!-- ── DARK ── -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Dark theme</div>
<div class="chrome dark">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-link">Stammbaum</div>
<div class="app-link">Admin</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<div class="sub-header">
<div class="back-btn">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="sh-divider"></div>
<div class="sh-doc-title">W-0311 · Divacca</div>
<div class="sh-persons">
<div class="sh-chip">
<div class="sh-av" style="background:#012851">KR</div>
<span class="sh-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
<div class="sh-chip">
<div class="sh-av" style="background:#5a2d6f">HR</div>
<span class="sh-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
<div class="sh-actions">
<div class="sh-btn-ghost">Details ▾</div>
<div class="sh-btn-ghost">Transkribieren</div>
<div class="sh-btn-primary">Bearbeiten</div>
<div class="sh-btn-icon">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</div>
</div>
</div>
<div class="meta-drawer">
<div class="meta-grid">
<div>
<div class="meta-col-head">Details</div>
<div class="meta-field">
<div class="meta-label">Datum</div>
<div class="meta-value"></div>
</div>
<div class="meta-field">
<div class="meta-label">Ort</div>
<div class="meta-value">Divacca</div>
</div>
<div class="meta-field">
<div class="meta-label">Status</div>
<div class="meta-value">Hochgeladen</div>
</div>
</div>
<div>
<div class="meta-col-head">Personen</div>
<div class="meta-field">
<div class="meta-label">Absender</div>
<div class="person-card">
<div class="p-av" style="background:#012851">KR</div>
<span class="p-name">Karl Raddatz</span>
<span class="pill">ELTERNTEIL</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label">Empfänger</div>
<div class="person-card">
<div class="p-av" style="background:#5a2d6f">HR</div>
<span class="p-name">Hans Raddatz</span>
<span class="pill">KIND</span>
</div>
</div>
</div>
<div>
<div class="meta-col-head">Schlagwörter</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Berlin</span>
</div>
</div>
</div>
</div>
<div class="pdf-area">
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
</div>
</div>
<p class="cap">Dark. Pills flip to rgba(0,199,177,.10) bg, #00c7b1 border, #f0efe9 text. Sub-header and metadata surfaces both use #011526.</p>
</div>
</div>
</div>
<!-- ══ SECTION 3 — TABLET (768 px) ══════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>3 · Tablet (768 px)</h2>
<p>
The 3-column metadata grid collapses to a single stacked column. The sub-header truncates the document
title and moves secondary actions behind a "…" overflow button. Pills remain inline next to person names in both locations.
</p>
</div>
<div class="responsive-grid">
<!-- Tablet light -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Tablet · 768 px · Light</div>
<div class="chrome" style="max-width:400px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<!-- Tablet sub-header: back + title truncated + overflow -->
<div class="sub-header-tablet">
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
<div class="sh-title-truncated">W-0311 · Divacca</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<div class="sh-btn-primary" style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center">Bearbeiten</div>
<div class="sh-overflow-btn">···</div>
</div>
</div>
<!-- Stacked metadata — Personen section with pills -->
<div class="meta-stacked">
<div class="meta-section-head">Personen</div>
<div style="margin-bottom:6px">
<div class="meta-label">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851;width:18px;height:18px;font-size:6px">KR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
</div>
</div>
<div style="margin-bottom:10px">
<div class="meta-label">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#5a2d6f;width:18px;height:18px;font-size:6px">HR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KIND</span>
</div>
</div>
<div class="meta-section-head">Details</div>
<div class="meta-label">Ort</div>
<div class="meta-value">Divacca</div>
<div class="meta-label" style="margin-top:6px">Status</div>
<div class="meta-value">Hochgeladen</div>
<div class="meta-label" style="margin-top:6px">Schlagwörter</div>
<div style="display:flex;gap:4px;margin-top:3px">
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
</div>
</div>
<p class="cap">Tablet light. 3-column metadata collapses to single column. Pills stay inline with names. Sub-header shows only title + primary action + overflow menu.</p>
</div>
<!-- Tablet dark -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Tablet · 768 px · Dark</div>
<div class="chrome dark" style="max-width:400px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<div class="sub-header-tablet" style="background:#011526;border-bottom:1px solid #0d3358">
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
<div style="display:flex;gap:4px;flex-shrink:0">
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center">Bearbeiten</div>
<div style="width:22px;height:22px;border:1.5px solid #0d3358;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#8b97a5;font-size:9px;font-weight:700">···</div>
</div>
</div>
<div style="padding:12px 14px;background:#011526;border-bottom:1px solid #0d3358;font-size:9px">
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Personen</div>
<div style="margin-bottom:6px">
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
</div>
</div>
<div style="margin-bottom:10px">
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">KIND</span>
</div>
</div>
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Details</div>
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Ort</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Status</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
<div style="display:flex;gap:4px;margin-top:3px">
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
</div>
</div>
<p class="cap">Tablet dark. Same collapse behaviour. Dark pill tokens apply throughout.</p>
</div>
</div>
</div>
<!-- ══ SECTION 4 — MOBILE (375 px) ══════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>4 · Mobile (375 px)</h2>
<p>
Sub-header is simplified to back arrow and document title only — no person chips in the bar.
Metadata is full-width single column. Each person row is <code>flex; align-items: center; flex-wrap: nowrap</code>
— avatar, name, and pill on one line. If the name is very long the row wraps gracefully before the pill.
Only primary action buttons are shown.
</p>
</div>
<div class="responsive-grid">
<!-- Mobile light -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Mobile · 375 px · Light</div>
<div class="chrome" style="max-width:260px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<!-- Mobile sub-header: back + title only -->
<div class="sub-header-mobile">
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
<div class="sh-title-mobile">W-0311 · Divacca</div>
<div style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
</div>
<!-- Mobile metadata: full-width stacked -->
<div class="meta-mobile">
<div class="m-label">Absender</div>
<div class="person-row-mobile">
<div class="p-av-sm" style="background:#012851">KR</div>
<span class="p-nm">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
</div>
<div class="m-label">Empfänger</div>
<div class="person-row-mobile">
<div class="p-av-sm" style="background:#5a2d6f">HR</div>
<span class="p-nm">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
</div>
<div class="m-label">Ort</div>
<div class="m-value">Divacca</div>
<div class="m-label">Status</div>
<div class="m-value">Hochgeladen</div>
<div class="m-label">Schlagwörter</div>
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
</div>
</div>
<p class="cap">Mobile light. No chips in sub-header — only title + primary action. Person rows: avatar + name + pill, flex-wrap:nowrap. Pill text drops to 6px to fit.</p>
</div>
<!-- Mobile dark -->
<div>
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Mobile · 375 px · Dark</div>
<div class="chrome dark" style="max-width:260px">
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
<div class="app-nav">
<div class="app-logo">FAMILIENARCHIV</div>
<div class="app-nav-r"><div class="app-av">MR</div></div>
</div>
<div style="height:48px;background:#011526;border-bottom:1px solid #0d3358;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0">
<div style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5;flex-shrink:0">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</div>
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
</div>
<div style="padding:10px 12px;background:#011526;border-bottom:1px solid #0d3358;font-size:8.5px">
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:2px">
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px;margin-top:6px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:8px">
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Hans Raddatz</span>
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Ort</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Status</div>
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
</div>
</div>
<div class="pdf-area" style="min-height:60px">
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
</div>
</div>
<p class="cap">Mobile dark. Pill tokens #00c7b1/#f0efe9 at reduced 6px font — still passes AA on dark surface.</p>
</div>
</div>
</div>
<!-- ══ SECTION 5 — EDGE CASES ════════════════════════════════════════════════ -->
<div class="section">
<div class="sh">
<h2>5 · Edge cases — when no pill is rendered</h2>
<p>Three cases where the pill is silently omitted. The person name renders as normal — no gap, no placeholder.</p>
</div>
<div class="edge-grid">
<!-- Edge 1: no family relationship -->
<div class="edge-card">
<div class="edge-head">No family relationship → no pill</div>
<div class="edge-body">
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851">KR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
<!-- no pill -->
</div>
</div>
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#888">ME</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Maria Engel</span>
<!-- no pill -->
</div>
</div>
<div class="no-badge">— no pill —</div>
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship === null</code> because the backend returns 404 (no kinship path). Name renders without trailing pill.</div>
</div>
</div>
<!-- Edge 2: social relationship (Kollegen) → pill shows label -->
<div class="edge-card">
<div class="edge-head">Social relationship (Kollegen) → pill shows label</div>
<div class="edge-body">
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851">KR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#3d6b5a">FW</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Fritz Weber</span>
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
</div>
</div>
<div class="edge-note">Non-family relationships (Kollege, Freund, etc.) returned by the inference endpoint still render as pills. The pill component is label-agnostic — it renders whatever <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship</code> provides.</div>
</div>
</div>
<!-- Edge 3: multiple receivers → no pill -->
<div class="edge-card">
<div class="edge-head">Multiple receivers → no pill</div>
<div class="edge-body">
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#012851">KR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
</div>
</div>
<div class="meta-field">
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#5a2d6f">HR</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Hans Raddatz</span>
</div>
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
<div class="p-av" style="background:#6a7a52">ER</div>
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Elfriede Raddatz</span>
</div>
</div>
<div class="no-badge">— no pill —</div>
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">receivers.length &gt; 1</code> — inference endpoint is never called, <code>inferredRelationship</code> is <code>null</code>. No pill on any person chip.</div>
</div>
</div>
</div>
</div>
<!-- ══ SECTION 6 — IMPLEMENTATION REFERENCE TABLE ═══════════════════════════ -->
<div class="section">
<div class="sh">
<h2>6 · Implementation reference</h2>
<p>Exact CSS/Tailwind values for every element of the pill and its context. Use these as the ground truth during implementation review.</p>
</div>
<div class="rules">
<table>
<thead>
<tr>
<th>Element</th>
<th>Tailwind / CSS</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Inline pill (light)</td>
<td><code>rounded-full border border-[#a1dcd8] bg-[rgba(161,220,216,.25)] px-2 py-0.5 text-[9px] font-bold uppercase tracking-[.07em] text-[#012851] ml-2 align-middle inline</code></td>
<td>Montserrat 9px 700. <code>ml-2</code> = 8px from name span. <code>vertical-align: middle</code> aligns cap-height to person name.</td>
</tr>
<tr>
<td>Inline pill (dark)</td>
<td><code>dark:bg-[rgba(0,199,177,.10)] dark:border-[#00c7b1] dark:text-[#f0efe9]</code></td>
<td>All three dark overrides applied together. Rest of pill class unchanged.</td>
</tr>
<tr>
<td>Person name span</td>
<td><code>font-sans text-[11px] text-[#4b5563] dark:text-[#9ca3af]</code> (sub-header) or <code>font-serif text-[9.5px] text-ink dark:text-[#f0efe9]</code> (metadata)</td>
<td>Name and pill share a <code>flex items-center gap-0</code> wrapper. Pill is the immediate next sibling of the name <code>&lt;span&gt;</code>.</td>
</tr>
<tr>
<td>Sub-header chip area</td>
<td><code>flex items-center gap-1.5</code></td>
<td>Wraps one sender chip + arrow + one receiver chip. Placed after the doc-title block, before action buttons.</td>
</tr>
<tr>
<td>Chip (avatar + name + pill)</td>
<td><code>flex items-center gap-1</code></td>
<td>Avatar, name span, and pill as three siblings inside the chip div.</td>
</tr>
<tr>
<td>Arrow between chips (sub-header)</td>
<td><code>h-2.5 w-2.5 shrink-0 text-[#a1dcd8] dark:text-[#00c7b1]</code> with <code>aria-hidden="true"</code></td>
<td>Arrow SVG carries no semantic information. DOM order (sender chip before receiver chip) conveys direction for assistive technology.</td>
</tr>
<tr>
<td>Person avatar (sub-header)</td>
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6px] font-bold text-white shrink-0</code></td>
<td>20×20 px. Initials in 6px bold white. Background colour is person-specific (set inline).</td>
</tr>
<tr>
<td>Person avatar (metadata)</td>
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6.5px] font-extrabold text-white shrink-0</code></td>
<td>Same 20×20 px. Slightly heavier weight (800) to match existing drawer card style.</td>
</tr>
<tr>
<td>Pill condition</td>
<td><code>{#if inferredRelationship} … {/if}</code> wraps both the sender pill and the receiver pill</td>
<td>Render only when <code>inferredRelationship !== null &amp;&amp; receivers.length === 1</code>. The check lives in <code>+page.server.ts</code>, not in the component.</td>
</tr>
<tr>
<td>Pill label value</td>
<td><code>inferredRelationship.labelFromA</code> next to sender, <code>inferredRelationship.labelFromB</code> next to receiver</td>
<td>Labels are pre-translated strings from the backend. No frontend i18n key needed for the label text itself.</td>
</tr>
<tr>
<td>Mobile person row</td>
<td><code>flex items-center gap-1 flex-nowrap</code></td>
<td><code>flex-wrap: nowrap</code> keeps avatar + name + pill on one line. If name overflows container, truncate name with <code>truncate</code>, never truncate the pill.</td>
</tr>
<tr>
<td>Mobile pill font-size</td>
<td><code>text-[6px]</code> at ≤375 px</td>
<td>Reduced from 9px (desktop) to 6px on mobile to fit without overflow. Contrast still passes AA at 6px bold.</td>
</tr>
<tr>
<td>Sub-header at mobile</td>
<td>Chips removed entirely from sub-header at <code>max-width: 767px</code></td>
<td>Sub-header shows only back arrow + document title + primary action button. Person chips with pills appear only in the metadata section on mobile.</td>
</tr>
</tbody>
</table>
</div>
<p style="font-size:10.5px;color:#888;margin-top:12px;line-height:1.7">
<strong style="color:#012851">Accessibility note:</strong> The pill text ("ELTERNTEIL", "KIND") is uppercase visually but the accessible name should be the mixed-case label from the backend (<code>labelFromA</code>). Apply <code>aria-label={labelFromA}</code> on the pill span so screen readers announce "Elternteil" not "E-L-T-E-R-N-T-E-I-L". The visual uppercase is achieved with CSS <code>text-transform: uppercase</code>, not by changing the source string.
</p>
</div>
</div><!-- /doc -->
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ bun.lockb
# Build artifacts
/.svelte-kit/
/.svelte-kit-backup/
/.svelte-kit.old/
# Generated files
/.svelte-kit-backup/
@@ -23,6 +22,5 @@ bun.lockb
# Test artifacts
/test-results/
/test-results.locked/
/e2e/.auth/
/coverage/

View File

@@ -1,197 +0,0 @@
# Frontend — Familienarchiv
## Overview
SvelteKit 2 application providing the Familienarchiv web UI. Server-side rendered (SSR) where beneficial, with client-side interactivity for document viewing, transcription, annotation, and admin workflows.
## Tech Stack
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
- **Language**: TypeScript 5.9
- **Styling**: Tailwind CSS 4.1 + custom brand utilities
- **Build Tool**: Vite 7
- **Adapter**: `@sveltejs/adapter-node` (Node.js server, not static)
- **i18n**: Paraglide.js 2.5 (`@inlang/paraglide-js`) — German (default), English, Spanish
- **API Client**: `openapi-fetch` + `openapi-typescript` (generated from backend OpenAPI spec)
- **PDF Rendering**: `pdfjs-dist` (PDF.js)
- **Testing**:
- Unit/Server: Vitest 4 (Node environment)
- Component: Vitest Browser Mode with Playwright (Chromium)
- E2E: Playwright (`frontend/e2e/`)
## Project Structure
```
src/
├── routes/ # SvelteKit file-based routing
│ ├── +layout.svelte # Global layout: header, nav, auth state
│ ├── +layout.server.ts # Loads current user, injects auth cookie
│ ├── +page.svelte # Home / document search dashboard
│ ├── documents/ # Document CRUD, detail, edit, upload
│ ├── persons/ # Person directory, detail, edit, merge
│ ├── briefwechsel/ # Bilateral conversation timeline
│ ├── chronik/ # Unified activity feed
│ ├── admin/ # User, group, tag, OCR, system management
│ ├── api/ # Internal API proxies (server-side only)
│ ├── login/ logout/ # Auth pages
│ └── ...
├── lib/
│ ├── components/ # Reusable Svelte components
│ │ ├── document/ # Document-specific components
│ │ ├── chronik/ # Activity feed components
│ │ └── user/ # User-related components
│ ├── generated/ # Auto-generated API types (openapi-typescript)
│ ├── server/ # Server-only utilities (db, auth helpers)
│ ├── services/ # Client-side service logic
│ ├── stores/ # Svelte stores (global state)
│ ├── types.ts # Shared TypeScript types
│ ├── errors.ts # Error code mapping (mirrors backend ErrorCode)
│ ├── api.server.ts # Typed API client factory
│ ├── utils.ts # Shared utilities
│ ├── relativeTime.ts # Time formatting
│ ├── search.ts # Search utilities
│ └── paraglide/ # Generated i18n code
├── hooks/ # SvelteKit hooks (handle, handleFetch)
└── actions/ # Custom Svelte actions (click outside, etc.)
```
## API Client Pattern
All server-side API calls use the typed client from `$lib/api.server.ts`:
```typescript
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
// Always check via response.ok, NOT result.error
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
```
Key rules:
- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined)
- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code
- Use `result.data!` after an ok check
For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
## Form Actions Pattern
```typescript
// +page.server.ts
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = formData.get('name') as string;
// ...
return fail(400, { error: 'message' }); // on error
throw redirect(303, '/target'); // on success
}
};
```
## Date Handling
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO to the backend.
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
```typescript
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
new Date(doc.documentDate + 'T12:00:00')
);
```
## Styling Conventions (Tailwind CSS 4)
Brand color utilities (defined in `layout.css`):
| Class | Value | Usage |
| ------------ | --------- | -------------------------------- |
| `brand-navy` | `#002850` | Primary text, buttons, headers |
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
| `brand-sand` | `#E4E2D7` | Page background, card borders |
Typography:
- `font-serif` (Merriweather) — body text, document titles, names
- `font-sans` (Montserrat) — labels, metadata, UI chrome
Card pattern for content sections:
```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section</h2>
<!-- content -->
</div>
```
## Key UI Components
| Component | Props | Description |
| -------------------- | ---------------------------------------------------- | ------------------------------------- |
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead |
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
| `PdfViewer` | `url`, `annotations`, `on:annotation` | PDF rendering with annotation overlay |
| `TranscriptionBlock` | `block`, `mode` | Read/edit transcription block |
| `DocumentTopBar` | `document` | Responsive document metadata header |
## How to Run
### Development
```bash
cd frontend
npm install
npm run dev # Dev server on port 5173 (or 3000 if --port 3000)
```
### Build & Preview
```bash
npm run build # Production build
npm run preview # Preview production build
```
### Code Quality
```bash
npm run lint # Prettier + ESLint check
npm run format # Auto-fix formatting
npm run check # svelte-check (type checking)
```
### Testing
```bash
npm run test # Vitest unit + server tests (headless)
npm run test:coverage # Coverage report (server project only)
npm run test:e2e # Playwright E2E tests
npm run test:e2e:headed # Playwright E2E with visible browser
npm run test:e2e:ui # Playwright UI mode
```
### Regenerate API Types
Requires backend running with `--spring.profiles.active=dev`:
```bash
npm run generate:api
```
## Vite Proxy
During development, `/api` calls are proxied to the Spring Boot backend. The proxy injects the `Authorization` header from the `auth_token` cookie automatically (see `vite.config.ts`).
## i18n (Paraglide)
Translations live in `messages/{de,en,es}.json`. The compiler generates type-safe helpers in `src/lib/paraglide/`. Run compilation manually with:
```bash
npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
```
Or let the Vite plugin handle it automatically during dev/build.

View File

@@ -1,141 +0,0 @@
# E2E Tests — Familienarchiv
## Overview
End-to-end tests for the Familienarchiv frontend using Playwright. These tests verify complete user flows across the full stack (SvelteKit frontend + Spring Boot backend + PostgreSQL + MinIO).
## Tech Stack
- **Test Runner**: Playwright (`@playwright/test`)
- **Browser**: Chromium (desktop)
- **Locale**: `de-DE` (ensures German language detection)
- **Auth**: Shared session cookie stored after setup
## Project Structure
```
frontend/e2e/
├── auth.setup.ts # Authentication setup — logs in and saves session
├── auth.spec.ts # Authentication flows (login, logout, register)
├── admin.spec.ts # Admin panel CRUD operations
├── annotations.spec.ts # Document annotation features
├── bottom-panel.spec.ts # Bottom panel / transcription panel
├── dashboard-*.spec.ts # Dashboard variants and screenshots
├── documents.spec.ts # Document upload, edit, search
├── focus-rings.spec.ts # Accessibility focus ring tests
├── header.spec.ts # Navigation header
├── history.spec.ts # Chronik / activity feed
├── korrespondenz.spec.ts # Correspondence timeline
├── lang.spec.ts # Language switching
├── password-reset.spec.ts # Password reset flow
├── permissions.spec.ts # Role-based access control
├── persons.spec.ts # Person directory CRUD
├── profile.spec.ts # User profile
├── theme.spec.ts # Dark/light mode
├── transcription.spec.ts # Transcription workflows
├── accessibility.spec.ts # Axe accessibility scans
├── fixtures/ # Test data fixtures
└── helpers/ # Test helper utilities
```
## Authentication Strategy
Tests share auth state via a stored session cookie:
1. **Setup** (`auth.setup.ts`): Logs in with test credentials and saves `storageState` to `e2e/.auth/user.json`
2. **Tests**: All test projects depend on `setup` and reuse the stored session
This avoids re-logging in for every test, but means tests **must run sequentially** (`fullyParallel: false`, `workers: 1`).
## Configuration
Config lives in `frontend/playwright.config.ts`:
| Setting | Value | Notes |
| --------------- | ----------------------- | ------------------------------ |
| `testDir` | `./e2e` | Test file location |
| `fullyParallel` | `false` | Shared auth state |
| `workers` | `1` | Sequential execution |
| `screenshot` | `'on'` | Always capture |
| `video` | `'retain-on-failure'` | Keep on failure |
| `trace` | `'retain-on-failure'` | Keep on failure |
| `baseURL` | `http://localhost:3000` | Overridable via `E2E_BASE_URL` |
The `webServer` config auto-starts `npm run dev -- --port 3000` if no server is detected at the base URL.
## How to Run
### Prerequisites
The full stack must be running (or the `webServer` config will start the frontend dev server):
```bash
# Start infrastructure
docker-compose up -d
# Ensure backend is healthy
curl http://localhost:8080/actuator/health
```
### Run E2E Tests
```bash
cd frontend
# Headless (CI mode)
npm run test:e2e
# With visible browser
npm run test:e2e:headed
# Interactive UI mode
npm run test:e2e:ui
# Run a specific test file
npx playwright test documents.spec.ts
# Run with a different base URL (e.g., docker frontend on 5173)
E2E_BASE_URL=http://localhost:5173 npx playwright test
```
## Writing New E2E Tests
1. Create a new `.spec.ts` file in `frontend/e2e/`
2. Use the shared auth state (no manual login needed)
3. Use page object patterns or helper functions from `helpers/`
4. Add `test-data-id` attributes to components for stable selectors
5. Run with `--debug` or `--ui` to troubleshoot
### Example Test Pattern
```typescript
import { test, expect } from '@playwright/test';
test('user can create a document', async ({ page }) => {
await page.goto('/documents/new');
await page.getByTestId('document-title').fill('Test Document');
await page.getByTestId('save-button').click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
});
```
## Accessibility Testing
`accessibility.spec.ts` runs Axe scans on key pages. Violations fail the test.
```bash
npx playwright test accessibility.spec.ts
```
## Troubleshooting
| Issue | Solution |
| --------------------- | ---------------------------------------- |
| Auth failures | Delete `e2e/.auth/user.json` and re-run |
| Backend not reachable | Ensure `docker-compose up -d` is running |
| Flaky tests | Increase timeout or add explicit waits |
| Screenshots missing | Check `test-results/e2e/` |
## CI Integration
E2E tests are **not** currently run in CI (the pipeline stops at unit/component tests). To add them, extend `infra/gitea/workflows/ci.yml` with a Playwright job that starts the full Docker Compose stack first.

View File

@@ -1,151 +0,0 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
/**
* Minimal Geschichten coverage. The deeper a11y / visual-regression suite is
* tracked separately; this file proves the core writer + reader journey works
* end-to-end against the real stack.
*
* Pre-requisite: V59 has granted BLOG_WRITE to the Administrators group, so
* the seeded admin user can author. The auth.setup project handles login.
*/
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
test.describe('Geschichten — writer + reader journey', () => {
test('admin can create a draft, publish it, and see it on the index', async ({ page }) => {
const title = `E2E story ${stamp()}`;
// Land on the index — empty state or pre-existing demo data is fine
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByRole('heading', { name: 'Geschichten', level: 1 })).toBeVisible();
// Click "Neue Geschichte" — visible because admin has BLOG_WRITE
await page.getByRole('link', { name: 'Neue Geschichte' }).click();
await page.waitForURL('/geschichten/new');
// Fill in title — the body editor is Tiptap and harder to script reliably
await page.getByPlaceholder('Titel der Geschichte').fill(title);
// Save as draft and verify we land on the detail page
await page.getByRole('button', { name: 'Entwurf speichern' }).click();
await page.waitForURL(/\/geschichten\/[^/]+$/);
// Capture the new id from the URL
const detailUrl = page.url();
const id = detailUrl.split('/').pop();
expect(id).toBeTruthy();
// Publish from the edit page
await page.getByRole('link', { name: 'Bearbeiten' }).click();
await page.waitForURL(/\/edit$/);
await page.getByRole('button', { name: 'Veröffentlichen' }).click();
await page.waitForURL(detailUrl);
// Index now shows the published story
await page.goto('/geschichten');
await expect(page.getByRole('link', { name: title })).toBeVisible();
});
test('reader is taken to a story detail when clicking a card', async ({ page }) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
// Use the first story link in the list (demo data exists; if not, the
// previous test seeded one). The link wraps the whole card.
const firstStory = page.locator('a[href^="/geschichten/"]').filter({ hasText: /.+/ }).first();
await expect(firstStory).toBeVisible();
await firstStory.click();
await page.waitForURL(/\/geschichten\/[^/]+$/);
await expect(page.locator('article')).toBeVisible();
});
test('multi-person filter: chips, URL params, and AND removal work end-to-end', async ({
page
}) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
// We need two distinct persons to filter by, but we don't want to couple this
// test to specific seed names. Strategy: type a single broadly-occurring vowel
// ("e" is present in the vast majority of German names), open the listbox,
// and pick whichever option matches the predicate.
//
// option DOM ids encode the person id as `${listboxId}-option-${personId}`,
// so we can identify the *first different* option without knowing the seed.
const PROBE = 'e';
async function openPicker() {
await page.getByRole('button', { name: /Person wählen/ }).click();
const input = page.getByRole('combobox', { name: /Person wählen/ });
await input.fill(PROBE);
// Wait for the listbox to be populated.
await expect(page.getByRole('option').first()).toBeVisible();
}
async function pickFirstOption(): Promise<string> {
const opt = page.getByRole('option').first();
const optId = (await opt.getAttribute('id')) ?? '';
const personId = optId.split('-option-')[1] ?? '';
expect(personId).not.toEqual('');
await opt.click();
return personId;
}
async function pickFirstOptionDifferentFrom(excludeId: string): Promise<string> {
// Iterate through visible options and return the first whose person id != excludeId.
const optionCount = await page.getByRole('option').count();
for (let i = 0; i < optionCount; i++) {
const candidate = page.getByRole('option').nth(i);
const optId = (await candidate.getAttribute('id')) ?? '';
const personId = optId.split('-option-')[1] ?? '';
if (personId && personId !== excludeId) {
await candidate.click();
return personId;
}
}
throw new Error(
`Expected at least two distinct persons matching "${PROBE}" in the seed, found only one.`
);
}
await openPicker();
const firstId = await pickFirstOption();
await page.waitForURL(/personId=/);
const firstIds = new URL(page.url()).searchParams.getAll('personId');
expect(firstIds).toEqual([firstId]);
await openPicker();
const secondId = await pickFirstOptionDifferentFrom(firstId);
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 2);
const secondIds = new URL(page.url()).searchParams.getAll('personId');
expect(secondIds).toEqual([firstId, secondId]);
expect(secondId).not.toEqual(firstId);
// Two chips visible — find them by their remove-aria-label pattern
const chipButtons = page.getByRole('button', { name: /aus Filter entfernen/ });
await expect(chipButtons).toHaveCount(2);
// Remove the first chip — URL drops to one param, only the second id remains
await chipButtons.first().click();
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 1);
const finalIds = new URL(page.url()).searchParams.getAll('personId');
expect(finalIds).toEqual([secondId]);
});
test('AxeBuilder finds no critical violations on the index', async ({ page }) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
// Filter to non-deferred severity. We don't gate the whole PR on a clean
// AxeBuilder run yet — Sara's review tracks the broader a11y backlog —
// but any "serious" or "critical" finding from this scan would block merge.
const blocking = results.violations.filter(
(v) => v.impact === 'serious' || v.impact === 'critical'
);
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
});
});

View File

@@ -1,163 +0,0 @@
import { test, expect, devices } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
const STORAGE_STATE = path.resolve(__dirname, '.auth/user.json');
/**
* E2E for issue #362 — Person @mentions, read-mode rendering + hover card (B20/B21).
*
* Strategy:
* - Create a document, a Person, and a transcription block whose text contains
* `@DisplayName` and whose mentionedPersons sidecar links to that person.
* - Open the document in read mode.
* - B20: page.hover() on the .person-mention link → hover card mounts.
* - B21: with context.setHasTouch(true), page.tap() on the link → navigates
* to /persons/{id} without ever showing the hover card.
*/
let docId: string;
let personId: string;
let docHref: string;
test.describe.configure({ mode: 'serial' });
test.describe('Person mention — read mode', () => {
test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
// 1. Person we will mention.
const personRes = await request.post('/api/persons', {
data: {
firstName: 'Auguste',
lastName: 'Raddatz',
personType: 'PERSON',
birthYear: 1882,
deathYear: 1944
}
});
if (!personRes.ok()) throw new Error(`Create person failed: ${personRes.status()}`);
const person = await personRes.json();
personId = person.id;
// 2. Document with a PDF so the transcription panel is mountable.
// Sara #3: timestamp the title so a previous run that crashed in beforeAll
// (and therefore skipped afterAll cleanup) cannot collide with this one.
const uniqueSuffix = Date.now();
const docRes = await request.post('/api/documents', {
multipart: {
title: `E2E Person Mention Read ${uniqueSuffix}`,
documentDate: '1945-05-08'
}
});
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
const doc = await docRes.json();
docId = doc.id;
docHref = `${baseURL}/documents/${docId}`;
await request.put(`/api/documents/${docId}`, {
multipart: {
title: doc.title as string,
documentDate: '1945-05-08',
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
// 3. Annotation to anchor the block on the page.
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
});
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
// 4. Block text contains @Auguste Raddatz; sidecar links it to personId.
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
data: {
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.1,
text: 'Brief an @Auguste Raddatz vom Mai 1944',
label: null,
mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }]
}
});
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
});
test.afterAll(async ({ request }) => {
if (docId) await request.delete(`/api/documents/${docId}`);
if (personId) await request.delete(`/api/persons/${personId}`);
});
test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => {
await page.goto(docHref);
await page.getByRole('button', { name: 'Transkription' }).click();
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
await expect(link).toBeVisible({ timeout: 5000 });
await expect(link).toHaveAttribute('href', `/persons/${personId}`);
// The @ trigger is stripped from the rendered text per spec
await expect(link).toHaveText('Auguste Raddatz');
});
test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => {
await page.goto(docHref);
await page.getByRole('button', { name: 'Transkription' }).click();
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
await link.hover();
const card = page.getByTestId('person-hover-card');
await expect(card).toBeVisible({ timeout: 5000 });
// Loaded state: person displayName is rendered inside the card
await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz');
// Footer link points to /persons/{id}
await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible();
});
test('B20: hover card unmounts on mouseleave', async ({ page }) => {
await page.goto(docHref);
await page.getByRole('button', { name: 'Transkription' }).click();
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
await link.hover();
await expect(page.getByTestId('person-hover-card')).toBeVisible();
// Move pointer away
await page.mouse.move(0, 0);
await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 });
});
test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => {
const context = await browser.newContext({
...devices['Pixel 7'],
storageState: STORAGE_STATE
});
const touchPage = await context.newPage();
try {
await touchPage.goto(docHref);
await touchPage.getByRole('button', { name: 'Transkription' }).click();
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
await expect(link).toBeVisible({ timeout: 5000 });
// Sara #2: assert no card *before* the tap so the test actually proves
// the touch device suppression worked, not just that we navigated away.
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
await link.tap();
// The card never mounted — the tap navigated directly per spec.
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
} finally {
await context.close();
}
});
});

View File

@@ -1,202 +0,0 @@
/**
* E2E regression tests for PersonTypeahead dropdown visibility.
*
* These tests verify that the dropdown list is never clipped by a parent
* container's stacking context — the root cause of issue #343.
*
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
* as required by the acceptance criteria.
*/
import { test, expect, type Page } from '@playwright/test';
/**
* Find a document edit URL to use as the test page.
* Falls back to /documents/new if no existing document is found.
*/
async function getDocumentEditUrl(page: Page): Promise<string> {
await page.goto('/');
await page.waitForLoadState('networkidle');
const firstDocLink = page.locator('a[href^="/documents/"]').first();
const href = await firstDocLink.getAttribute('href').catch(() => null);
if (href) {
return `${href}/edit`;
}
return '/documents/new';
}
/** Wait for the listbox to become visible after triggering a search. */
async function waitForListbox(page: Page): Promise<void> {
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 });
}
test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
// Find the sender typeahead input (the visible text input, not the hidden one)
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
// Type to trigger the dropdown
await senderInput.click();
await senderInput.fill('a');
// Wait for the dropdown to appear (handles debounce automatically)
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
// Verify the bounding box is within the viewport (not clipped)
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(720);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' });
});
test('dropdown is positioned below the input field (not hidden behind parent)', async ({
page
}) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
const inputBox = await senderInput.boundingBox();
expect(inputBox).not.toBeNull();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const dropdownBox = await dropdown.boundingBox();
expect(dropdownBox).not.toBeNull();
// Dropdown must appear below the input, not on top or clipped behind it
expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' });
});
});
test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
test.use({ viewport: { width: 768, height: 1024 } });
test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(1024);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' });
});
});
test.describe('PersonTypeahead — keyboard navigation', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('ArrowDown moves focus to the first option', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
await senderInput.press('ArrowDown');
// First option should now be the active descendant
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
expect(activeDescendant).toBeTruthy();
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
});
test('Escape key closes the dropdown', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
await senderInput.press('Escape');
await expect(dropdown).not.toBeVisible();
});
test('aria-expanded is true when dropdown is open', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
// Initially closed
const initialExpanded = await senderInput.getAttribute('aria-expanded');
expect(initialExpanded).toBe('false');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const expanded = await senderInput.getAttribute('aria-expanded');
expect(expanded).toBe('true');
});
});
test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('clicking outside a fixed-position dropdown closes it', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
// Click somewhere else on the page
await page.click('body', { position: { x: 10, y: 10 } });
await expect(dropdown).not.toBeVisible();
});
});

View File

@@ -1,60 +0,0 @@
import { test, expect } from '@playwright/test';
// Tests skipped until Playwright Chromium is installed in CI — see issue #363.
test.describe('Stammbaum — issue #358', () => {
test.skip();
test('nav swap: /briefwechsel still renders without 404', async ({ page }) => {
// Plan journey 4: the /briefwechsel route must stay intact even though the
// AppNav now points at /stammbaum.
const response = await page.goto('/briefwechsel');
expect(response?.status()).toBeLessThan(400);
await expect(page).toHaveURL(/\/briefwechsel/);
});
test('/stammbaum renders the page heading', async ({ page }) => {
await page.goto('/stammbaum');
await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
});
test('/stammbaum either shows an empty state or at least one node', async ({ page }) => {
// Plan journey 3 (empty branch) and journey 1 (populated branch) covered jointly:
// the test passes whenever the page renders one of the two coherent states.
await page.goto('/stammbaum');
const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' });
const anyNode = page.locator('svg[role="img"][aria-label="Stammbaum"] g[role="button"]');
await expect(async () => {
const emptyVisible = await empty.isVisible().catch(() => false);
const nodeCount = await anyNode.count();
expect(emptyVisible || nodeCount > 0).toBe(true);
}).toPass();
if (await empty.isVisible().catch(() => false)) {
await expect(page.getByRole('link', { name: /Zur Personenliste/ })).toBeVisible();
}
});
test('person edit Stammbaum card surfaces the year-range error', async ({ page }) => {
// Plan task 36: Bis < Von triggers the inline error and keeps the form unsubmitted.
// We pick the first person, open the edit page, expand the add-rel form, and
// inspect the validation message bound to the Bis field.
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
await expect(page).toHaveURL(/\/persons\/[^/]+/);
await page.goto(page.url() + '/edit');
// Open the add-rel form
const addBtn = page.getByRole('button', { name: /Beziehung hinzufügen/i });
await addBtn.click();
// Enter Von 1935, Bis 1920 → expect the year-range error
const fromInput = page.locator('input[name="fromYear"]');
const toInput = page.locator('input[name="toYear"]');
await fromInput.fill('1935');
await toInput.fill('1920');
await expect(page.locator('#add-rel-year-error')).toBeVisible();
await expect(page.locator('#add-rel-year-error')).toContainText(/Bis.*Von|nicht vor/i);
});
});

View File

@@ -12,7 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
{ ignores: ['src/paraglide/**', '.svelte-kit.old/**'] },
{ ignores: ['src/paraglide/**'] },
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,

View File

@@ -23,8 +23,6 @@
"nav_conversations": "Briefwechsel",
"nav_admin": "Admin",
"nav_logout": "Abmelden",
"theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
"btn_save": "Speichern",
"btn_cancel": "Abbrechen",
"btn_confirm": "Bestätigen",
@@ -228,7 +226,6 @@
"admin_perm_read_all": "Nur lesen",
"admin_perm_annotate_all": "Lesen & Annotieren",
"admin_perm_write_all": "Lesen & Schreiben",
"admin_perm_blog_write": "Geschichten schreiben",
"admin_perm_admin": "Vollzugriff (Admin)",
"admin_perm_admin_user": "Benutzer verwalten",
"admin_perm_admin_tag": "Schlagworte verwalten",
@@ -421,15 +418,6 @@
"notification_unread": "ungelesen",
"mention_btn_label": "Person erwähnen",
"mention_popup_empty": "Keine Nutzer gefunden",
"person_mention_open_link": "Zur Person",
"person_mention_hover_hint": "Klick öffnet Seite",
"person_mention_load_error": "Person konnte nicht geladen werden.",
"person_mention_loading": "Lade Person…",
"person_mention_popup_empty": "Keine Personen gefunden",
"person_mention_btn_label": "Person verlinken",
"person_mention_create_new": "Neue Person anlegen",
"transcription_editor_aria_label": "Transkriptionstext",
"person_born_name_prefix": "geb.",
"page_title_home": "Archiv",
"page_title_persons": "Personen",
"page_title_admin": "Administration",
@@ -503,7 +491,7 @@
"doc_details_more_receivers": "+{count} weitere",
"transcription_mode_label": "Transkribieren",
"transcription_mode_stop": "Fertig",
"transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen",
"transcription_block_placeholder": "Text hier eingeben...",
"transcription_block_save_saving": "Speichere...",
"transcription_block_save_saved": "Gespeichert",
"transcription_block_save_error": "Nicht gespeichert",
@@ -828,7 +816,6 @@
"pagination_next": "Weiter",
"pagination_page_of": "Seite {page} von {total}",
"pagination_nav_label": "Seitennavigation",
"pagination_page_button": "Seite {page}",
"common_opens_new_tab": "(öffnet in neuem Tab)",
@@ -917,133 +904,5 @@
"bulk_edit_loading": "Dokumente werden geladen…",
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
"bulk_edit_topbar_title": "Massenbearbeitung",
"bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"geschichten_index_title": "Geschichten",
"geschichten_new_button": "Neue Geschichte",
"geschichten_filter_all_pill": "Alle",
"geschichten_filter_choose_person": "Person wählen",
"geschichten_filter_aria_label": "Person filtern",
"geschichten_filter_remove_chip": "{name} aus Filter entfernen",
"geschichten_filter_and_hint": "Es werden nur Geschichten gezeigt, in denen alle ausgewählten Personen vorkommen.",
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
"geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}",
"geschichten_persons_section": "Personen in dieser Geschichte",
"geschichten_documents_section": "Erwähnte Dokumente",
"geschichten_card_heading": "Geschichten",
"geschichten_card_write_action": "+ Geschichte schreiben",
"geschichten_card_attach_action": "+ Geschichte anhängen",
"geschichten_card_show_all_for_person": "Alle Geschichten zu {name}",
"geschichten_card_show_all": "Alle anzeigen",
"geschichte_editor_title_placeholder": "Titel der Geschichte",
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
"geschichte_editor_status_draft": "ENTWURF",
"geschichte_editor_status_published": "VERÖFFENTLICHT",
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
"geschichte_editor_status_published_hint": "Öffentlich sichtbar für alle Leser.",
"geschichte_editor_save_hint_draft": "Alle Änderungen werden als Entwurf gespeichert.",
"geschichte_editor_save_hint_published": "Änderungen sind sofort live.",
"geschichte_editor_save_draft": "Entwurf speichern",
"geschichte_editor_publish": "Veröffentlichen",
"geschichte_editor_save": "Speichern",
"geschichte_editor_unpublish": "Zurück zu Entwurf",
"geschichte_editor_title_required": "Bitte gib einen Titel ein.",
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
"geschichte_editor_personen_heading": "Personen",
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
"geschichte_editor_dokumente_heading": "Dokumente",
"geschichte_editor_dokumente_hint": "Welche Briefe oder Dokumente sind Teil dieser Geschichte?",
"geschichte_editor_search_person": "Person suchen…",
"geschichte_editor_search_document": "Dokument suchen…",
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
"geschichte_editor_toolbar_italic": "Kursiv (Strg+I)",
"geschichte_editor_toolbar_h2": "Überschrift",
"geschichte_editor_toolbar_h3": "Unterüberschrift",
"geschichte_editor_toolbar_ul": "Aufzählung",
"geschichte_editor_toolbar_ol": "Nummerierte Liste",
"geschichte_delete_confirm_title": "Geschichte löschen?",
"geschichte_delete_confirm_body": "Diese Aktion kann nicht rückgängig gemacht werden. Die Geschichte wird dauerhaft gelöscht und aus allen verlinkten Personen- und Dokumentseiten entfernt.",
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
"relation_parent_of": "Elternteil von",
"relation_child_of": "Kind von",
"relation_spouse_of": "Ehegatte",
"relation_sibling_of": "Geschwister",
"relation_friend": "Freund",
"relation_colleague": "Kollege",
"relation_employer": "Arbeitgeber",
"relation_doctor": "Arzt",
"relation_neighbor": "Nachbar",
"relation_other": "Sonstige",
"relation_inferred_parent": "Elternteil",
"relation_inferred_child": "Kind",
"relation_inferred_spouse": "Ehegatte",
"relation_inferred_sibling": "Geschwister",
"relation_inferred_grandparent": "Großelternteil",
"relation_inferred_grandchild": "Enkelkind",
"relation_inferred_great_grandparent": "Urgroßelternteil",
"relation_inferred_great_grandchild": "Urenkel",
"relation_inferred_uncle_aunt": "Onkel/Tante",
"relation_inferred_niece_nephew": "Nichte/Neffe",
"relation_inferred_great_uncle_aunt": "Großonkel/Großtante",
"relation_inferred_great_niece_nephew": "Großnichte/Großneffe",
"relation_inferred_inlaw_parent": "Schwiegerelternteil",
"relation_inferred_inlaw_child": "Schwiegerkind",
"relation_inferred_sibling_inlaw": "Schwager/Schwägerin",
"relation_inferred_cousin_1": "Cousin/Cousine",
"relation_inferred_distant": "Weitläufige Verwandtschaft",
"doc_details_field_relationship": "Verwandtschaft",
"stammbaum_empty_heading": "Noch keine Familienmitglieder",
"stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.",
"stammbaum_empty_link": "→ Zur Personenliste",
"stammbaum_panel_direct_rels": "Direkte Beziehungen",
"stammbaum_panel_derived_rels": "Abgeleitete Beziehungen",
"stammbaum_panel_to_person": "Zur Personenseite →",
"stammbaum_panel_add_rel": "+ Beziehung hinzufügen",
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
"stammbaum_zoom_in": "Vergrößern",
"stammbaum_zoom_out": "Verkleinern",
"stammbaum_generations": "Generationen",
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
"relation_year_from": "ab {year}",
"relation_year_to": "bis {year}",
"relation_year_error_bis_before_von": "Bis-Jahr darf nicht vor Von-Jahr liegen.",
"relation_label_family_member": "Als Familienmitglied",
"relation_toggle_add_to_tree": "Zum Stammbaum hinzufügen",
"relation_toggle_remove_from_tree": "Aus Stammbaum entfernen",
"relation_label_in_tree": "Erscheint im Stammbaum",
"relation_label_view_in_tree": "Ansehen →",
"relation_label_direct": "Direkte Beziehungen",
"relation_label_derived": "Abgeleitete Beziehungen",
"relation_btn_add": "Hinzufügen",
"relation_btn_save": "Speichern",
"relation_btn_cancel": "Abbrechen",
"relation_form_group_family": "Familie",
"relation_form_group_social": "Sozial",
"relation_form_field_type": "Typ",
"relation_form_field_from_year": "Von Jahr",
"relation_form_field_to_year": "Bis Jahr",
"relation_form_year_placeholder": "z.B. 1920",
"person_relationships_heading": "Beziehungen",
"person_relationships_empty": "Noch keine Beziehungen bekannt."
"bulk_edit_count_pill": "{count} werden bearbeitet"
}

View File

@@ -23,8 +23,6 @@
"nav_conversations": "Letters",
"nav_admin": "Admin",
"nav_logout": "Sign out",
"theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode",
"btn_save": "Save",
"btn_cancel": "Cancel",
"btn_confirm": "Confirm",
@@ -228,7 +226,6 @@
"admin_perm_read_all": "Read only",
"admin_perm_annotate_all": "Read & Annotate",
"admin_perm_write_all": "Read & Write",
"admin_perm_blog_write": "Write stories",
"admin_perm_admin": "Full access (Admin)",
"admin_perm_admin_user": "Manage users",
"admin_perm_admin_tag": "Manage tags",
@@ -421,15 +418,6 @@
"notification_unread": "unread",
"mention_btn_label": "Mention person",
"mention_popup_empty": "No users found",
"person_mention_open_link": "Open person",
"person_mention_hover_hint": "Click opens the page",
"person_mention_load_error": "Could not load person.",
"person_mention_loading": "Loading person…",
"person_mention_popup_empty": "No persons found",
"person_mention_btn_label": "Link person",
"person_mention_create_new": "Create new person",
"transcription_editor_aria_label": "Transcription text",
"person_born_name_prefix": "née",
"page_title_home": "Archive",
"page_title_persons": "Persons",
"page_title_admin": "Administration",
@@ -503,7 +491,7 @@
"doc_details_more_receivers": "+{count} more",
"transcription_mode_label": "Transcribe",
"transcription_mode_stop": "Done",
"transcription_block_placeholder": "Type text — use @name to link a person from the archive",
"transcription_block_placeholder": "Type text here...",
"transcription_block_save_saving": "Saving...",
"transcription_block_save_saved": "Saved",
"transcription_block_save_error": "Not saved",
@@ -828,7 +816,6 @@
"pagination_next": "Next",
"pagination_page_of": "Page {page} of {total}",
"pagination_nav_label": "Pagination",
"pagination_page_button": "Page {page}",
"common_opens_new_tab": "(opens in new tab)",
@@ -917,133 +904,5 @@
"bulk_edit_loading": "Loading documents…",
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
"bulk_edit_topbar_title": "Bulk edit",
"bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree",
"nav_geschichten": "Stories",
"error_geschichte_not_found": "The story was not found.",
"geschichten_index_title": "Stories",
"geschichten_new_button": "New story",
"geschichten_filter_all_pill": "All",
"geschichten_filter_choose_person": "Choose person",
"geschichten_filter_aria_label": "Filter by person",
"geschichten_filter_remove_chip": "Remove {name} from filter",
"geschichten_filter_and_hint": "Only stories that include every selected person are shown.",
"geschichten_empty_for_person": "No stories found for {name}.",
"geschichten_empty_for_persons": "No stories found for {names}.",
"geschichten_empty_no_filter": "There are no published stories yet.",
"geschichten_back_to_index": "Back to stories",
"geschichten_published_on": "published on {date}",
"geschichten_persons_section": "People in this story",
"geschichten_documents_section": "Referenced documents",
"geschichten_card_heading": "Stories",
"geschichten_card_write_action": "+ Write a story",
"geschichten_card_attach_action": "+ Attach a story",
"geschichten_card_show_all_for_person": "All stories about {name}",
"geschichten_card_show_all": "Show all",
"geschichte_editor_title_placeholder": "Story title",
"geschichte_editor_body_placeholder": "Write your story here…",
"geschichte_editor_status_draft": "DRAFT",
"geschichte_editor_status_published": "PUBLISHED",
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
"geschichte_editor_status_published_hint": "Visible to all readers.",
"geschichte_editor_save_hint_draft": "All changes are saved as a draft.",
"geschichte_editor_save_hint_published": "Changes go live immediately.",
"geschichte_editor_save_draft": "Save draft",
"geschichte_editor_publish": "Publish",
"geschichte_editor_save": "Save",
"geschichte_editor_unpublish": "Back to draft",
"geschichte_editor_title_required": "Please enter a title.",
"geschichte_editor_unsaved_changes": "You have unsaved changes — leave anyway?",
"geschichte_editor_personen_heading": "People",
"geschichte_editor_personen_hint": "Which historical persons appear in this story?",
"geschichte_editor_dokumente_heading": "Documents",
"geschichte_editor_dokumente_hint": "Which letters or documents are part of this story?",
"geschichte_editor_search_person": "Search person…",
"geschichte_editor_search_document": "Search document…",
"geschichte_editor_toolbar_bold": "Bold (Ctrl+B)",
"geschichte_editor_toolbar_italic": "Italic (Ctrl+I)",
"geschichte_editor_toolbar_h2": "Heading",
"geschichte_editor_toolbar_h3": "Subheading",
"geschichte_editor_toolbar_ul": "Bulleted list",
"geschichte_editor_toolbar_ol": "Numbered list",
"geschichte_delete_confirm_title": "Delete story?",
"geschichte_delete_confirm_body": "This action cannot be undone. The story will be permanently deleted and removed from all linked person and document pages.",
"error_relationship_not_found": "Relationship not found.",
"error_circular_relationship": "This relationship would form a cycle.",
"error_duplicate_relationship": "This relationship already exists.",
"relation_parent_of": "Parent of",
"relation_child_of": "Child of",
"relation_spouse_of": "Spouse",
"relation_sibling_of": "Sibling",
"relation_friend": "Friend",
"relation_colleague": "Colleague",
"relation_employer": "Employer",
"relation_doctor": "Doctor",
"relation_neighbor": "Neighbour",
"relation_other": "Other",
"relation_inferred_parent": "Parent",
"relation_inferred_child": "Child",
"relation_inferred_spouse": "Spouse",
"relation_inferred_sibling": "Sibling",
"relation_inferred_grandparent": "Grandparent",
"relation_inferred_grandchild": "Grandchild",
"relation_inferred_great_grandparent": "Great-grandparent",
"relation_inferred_great_grandchild": "Great-grandchild",
"relation_inferred_uncle_aunt": "Uncle/Aunt",
"relation_inferred_niece_nephew": "Niece/Nephew",
"relation_inferred_great_uncle_aunt": "Great-uncle/Aunt",
"relation_inferred_great_niece_nephew": "Great-niece/Nephew",
"relation_inferred_inlaw_parent": "Parent-in-law",
"relation_inferred_inlaw_child": "Child-in-law",
"relation_inferred_sibling_inlaw": "Sibling-in-law",
"relation_inferred_cousin_1": "Cousin",
"relation_inferred_distant": "Distant relative",
"doc_details_field_relationship": "Relationship",
"stammbaum_empty_heading": "No family members yet",
"stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.",
"stammbaum_empty_link": "→ Go to person list",
"stammbaum_panel_direct_rels": "Direct relationships",
"stammbaum_panel_derived_rels": "Derived relationships",
"stammbaum_panel_to_person": "Go to person page →",
"stammbaum_panel_add_rel": "+ Add relationship",
"stammbaum_relationships_heading": "Family tree & relationships",
"stammbaum_zoom_in": "Zoom in",
"stammbaum_zoom_out": "Zoom out",
"stammbaum_generations": "Generations",
"relation_error_duplicate": "This relationship already exists.",
"relation_error_circular": "This relationship would form a cycle.",
"relation_error_self": "A person cannot be related to themselves.",
"relation_year_from": "from {year}",
"relation_year_to": "until {year}",
"relation_year_error_bis_before_von": "End year must not precede start year.",
"relation_label_family_member": "Family member",
"relation_toggle_add_to_tree": "Add to family tree",
"relation_toggle_remove_from_tree": "Remove from family tree",
"relation_label_in_tree": "Appears in the family tree",
"relation_label_view_in_tree": "View →",
"relation_label_direct": "Direct relationships",
"relation_label_derived": "Derived relationships",
"relation_btn_add": "Add",
"relation_btn_save": "Save",
"relation_btn_cancel": "Cancel",
"relation_form_group_family": "Family",
"relation_form_group_social": "Social",
"relation_form_field_type": "Type",
"relation_form_field_from_year": "From year",
"relation_form_field_to_year": "To year",
"relation_form_year_placeholder": "e.g. 1920",
"person_relationships_heading": "Relationships",
"person_relationships_empty": "No relationships known yet."
"bulk_edit_count_pill": "{count} will be edited"
}

View File

@@ -23,8 +23,6 @@
"nav_conversations": "Cartas",
"nav_admin": "Admin",
"nav_logout": "Cerrar sesión",
"theme_toggle_to_light": "Cambiar a modo claro",
"theme_toggle_to_dark": "Cambiar a modo oscuro",
"btn_save": "Guardar",
"btn_cancel": "Cancelar",
"btn_confirm": "Confirmar",
@@ -228,7 +226,6 @@
"admin_perm_read_all": "Solo lectura",
"admin_perm_annotate_all": "Leer y anotar",
"admin_perm_write_all": "Leer y escribir",
"admin_perm_blog_write": "Escribir historias",
"admin_perm_admin": "Acceso completo (Admin)",
"admin_perm_admin_user": "Gestionar usuarios",
"admin_perm_admin_tag": "Gestionar etiquetas",
@@ -421,15 +418,6 @@
"notification_unread": "no leído",
"mention_btn_label": "Mencionar persona",
"mention_popup_empty": "No se encontraron usuarios",
"person_mention_open_link": "Ir a la persona",
"person_mention_hover_hint": "Clic abre la página",
"person_mention_load_error": "No se pudo cargar la persona.",
"person_mention_loading": "Cargando persona…",
"person_mention_popup_empty": "No se encontraron personas",
"person_mention_btn_label": "Vincular persona",
"person_mention_create_new": "Crear nueva persona",
"transcription_editor_aria_label": "Texto de transcripción",
"person_born_name_prefix": "n.",
"page_title_home": "Archivo",
"page_title_persons": "Personas",
"page_title_admin": "Administración",
@@ -503,7 +491,7 @@
"doc_details_more_receivers": "+{count} más",
"transcription_mode_label": "Transcribir",
"transcription_mode_stop": "Listo",
"transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo",
"transcription_block_placeholder": "Escriba el texto aquí...",
"transcription_block_save_saving": "Guardando...",
"transcription_block_save_saved": "Guardado",
"transcription_block_save_error": "No guardado",
@@ -828,7 +816,6 @@
"pagination_next": "Siguiente",
"pagination_page_of": "Página {page} de {total}",
"pagination_nav_label": "Paginación",
"pagination_page_button": "Página {page}",
"common_opens_new_tab": "(abre en pestaña nueva)",
@@ -917,133 +904,5 @@
"bulk_edit_loading": "Cargando documentos…",
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
"bulk_edit_topbar_title": "Edición masiva",
"bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias",
"error_geschichte_not_found": "No se encontró la historia.",
"geschichten_index_title": "Historias",
"geschichten_new_button": "Nueva historia",
"geschichten_filter_all_pill": "Todas",
"geschichten_filter_choose_person": "Elegir persona",
"geschichten_filter_aria_label": "Filtrar por persona",
"geschichten_filter_remove_chip": "Quitar {name} del filtro",
"geschichten_filter_and_hint": "Solo se muestran las historias que incluyen a todas las personas seleccionadas.",
"geschichten_empty_for_person": "No hay historias para {name}.",
"geschichten_empty_for_persons": "No hay historias para {names}.",
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
"geschichten_back_to_index": "Volver a Historias",
"geschichten_published_on": "publicada el {date}",
"geschichten_persons_section": "Personas en esta historia",
"geschichten_documents_section": "Documentos mencionados",
"geschichten_card_heading": "Historias",
"geschichten_card_write_action": "+ Escribir historia",
"geschichten_card_attach_action": "+ Adjuntar historia",
"geschichten_card_show_all_for_person": "Todas las historias sobre {name}",
"geschichten_card_show_all": "Mostrar todas",
"geschichte_editor_title_placeholder": "Título de la historia",
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
"geschichte_editor_status_draft": "BORRADOR",
"geschichte_editor_status_published": "PUBLICADA",
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
"geschichte_editor_status_published_hint": "Visible para todos los lectores.",
"geschichte_editor_save_hint_draft": "Los cambios se guardan como borrador.",
"geschichte_editor_save_hint_published": "Los cambios se publican inmediatamente.",
"geschichte_editor_save_draft": "Guardar borrador",
"geschichte_editor_publish": "Publicar",
"geschichte_editor_save": "Guardar",
"geschichte_editor_unpublish": "Volver a borrador",
"geschichte_editor_title_required": "Por favor ingresa un título.",
"geschichte_editor_unsaved_changes": "Tienes cambios no guardados — ¿salir igualmente?",
"geschichte_editor_personen_heading": "Personas",
"geschichte_editor_personen_hint": "¿Qué personas históricas aparecen en esta historia?",
"geschichte_editor_dokumente_heading": "Documentos",
"geschichte_editor_dokumente_hint": "¿Qué cartas o documentos forman parte de esta historia?",
"geschichte_editor_search_person": "Buscar persona…",
"geschichte_editor_search_document": "Buscar documento…",
"geschichte_editor_toolbar_bold": "Negrita (Ctrl+B)",
"geschichte_editor_toolbar_italic": "Cursiva (Ctrl+I)",
"geschichte_editor_toolbar_h2": "Encabezado",
"geschichte_editor_toolbar_h3": "Subencabezado",
"geschichte_editor_toolbar_ul": "Lista con viñetas",
"geschichte_editor_toolbar_ol": "Lista numerada",
"geschichte_delete_confirm_title": "¿Eliminar historia?",
"geschichte_delete_confirm_body": "Esta acción no se puede deshacer. La historia se eliminará permanentemente y se quitará de todas las páginas de personas y documentos vinculados.",
"error_relationship_not_found": "La relación no fue encontrada.",
"error_circular_relationship": "Esta relación crearía un ciclo.",
"error_duplicate_relationship": "Esta relación ya existe.",
"relation_parent_of": "Progenitor de",
"relation_child_of": "Hijo/a de",
"relation_spouse_of": "Cónyuge",
"relation_sibling_of": "Hermano/a",
"relation_friend": "Amigo/a",
"relation_colleague": "Colega",
"relation_employer": "Empleador",
"relation_doctor": "Médico",
"relation_neighbor": "Vecino/a",
"relation_other": "Otro",
"relation_inferred_parent": "Progenitor",
"relation_inferred_child": "Hijo/a",
"relation_inferred_spouse": "Cónyuge",
"relation_inferred_sibling": "Hermano/a",
"relation_inferred_grandparent": "Abuelo/a",
"relation_inferred_grandchild": "Nieto/a",
"relation_inferred_great_grandparent": "Bisabuelo/a",
"relation_inferred_great_grandchild": "Bisnieto/a",
"relation_inferred_uncle_aunt": "Tío/Tía",
"relation_inferred_niece_nephew": "Sobrino/a",
"relation_inferred_great_uncle_aunt": "Tío/a abuelo/a",
"relation_inferred_great_niece_nephew": "Sobrino/a nieto/a",
"relation_inferred_inlaw_parent": "Suegro/a",
"relation_inferred_inlaw_child": "Yerno/Nuera",
"relation_inferred_sibling_inlaw": "Cuñado/a",
"relation_inferred_cousin_1": "Primo/a",
"relation_inferred_distant": "Pariente lejano",
"doc_details_field_relationship": "Parentesco",
"stammbaum_empty_heading": "Aún no hay miembros de la familia",
"stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.",
"stammbaum_empty_link": "→ Ir a la lista de personas",
"stammbaum_panel_direct_rels": "Relaciones directas",
"stammbaum_panel_derived_rels": "Relaciones derivadas",
"stammbaum_panel_to_person": "Ir a la persona →",
"stammbaum_panel_add_rel": "+ Añadir relación",
"stammbaum_relationships_heading": "Árbol genealógico & relaciones",
"stammbaum_zoom_in": "Acercar",
"stammbaum_zoom_out": "Alejar",
"stammbaum_generations": "Generaciones",
"relation_error_duplicate": "Esta relación ya existe.",
"relation_error_circular": "Esta relación crearía un ciclo.",
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
"relation_year_from": "desde {year}",
"relation_year_to": "hasta {year}",
"relation_year_error_bis_before_von": "El año final no puede ser anterior al año inicial.",
"relation_label_family_member": "Miembro de la familia",
"relation_toggle_add_to_tree": "Añadir al árbol genealógico",
"relation_toggle_remove_from_tree": "Quitar del árbol genealógico",
"relation_label_in_tree": "Aparece en el árbol genealógico",
"relation_label_view_in_tree": "Ver →",
"relation_label_direct": "Relaciones directas",
"relation_label_derived": "Relaciones derivadas",
"relation_btn_add": "Añadir",
"relation_btn_save": "Guardar",
"relation_btn_cancel": "Cancelar",
"relation_form_group_family": "Familia",
"relation_form_group_social": "Social",
"relation_form_field_type": "Tipo",
"relation_form_field_from_year": "Desde año",
"relation_form_field_to_year": "Hasta año",
"relation_form_year_placeholder": "ej. 1920",
"person_relationships_heading": "Relaciones",
"person_relationships_empty": "Aún no se conocen relaciones."
"bulk_edit_count_pill": "Se editarán {count}"
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,11 +21,7 @@
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
},
"dependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/extension-mention": "3.22.5",
"@tiptap/starter-kit": "3.22.5",
"diff": "^8.0.3",
"isomorphic-dompurify": "^3.12.0",
"openapi-fetch": "^0.13.5",
"pdfjs-dist": "^5.5.207"
},

View File

@@ -1,202 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type RelationType = NonNullable<RelationshipDTO['relationType']>;
export type RelFormData = {
relatedPersonId: string;
relationType: RelationType;
fromYear?: number;
toYear?: number;
};
interface Props {
personId: string;
onSubmit?: (data: RelFormData) => Promise<void>;
}
let { personId, onSubmit }: Props = $props();
let open = $state(false);
let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state('');
let addRelatedPersonName = $state('');
let addFromYear = $state('');
let addToYear = $state('');
let callbackError = $state<string | null>(null);
const yearError = $derived.by(() => {
const from = addFromYear.trim();
const to = addToYear.trim();
if (!from || !to) return null;
const fromInt = parseInt(from, 10);
const toInt = parseInt(to, 10);
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
});
const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
);
const submitDisabled = $derived(
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
function reset() {
addType = 'PARENT_OF';
addRelatedPersonId = '';
addRelatedPersonName = '';
addFromYear = '';
addToYear = '';
callbackError = null;
}
function cancel() {
open = false;
reset();
}
async function handleCallbackSubmit(event: Event) {
event.preventDefault();
if (submitDisabled || !onSubmit) return;
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
const from = parseInt(addFromYear.trim(), 10);
if (!Number.isNaN(from)) data.fromYear = from;
const to = parseInt(addToYear.trim(), 10);
if (!Number.isNaN(to)) data.toYear = to;
try {
await onSubmit(data);
open = false;
reset();
} catch {
callbackError = m.error_internal_error();
}
}
</script>
{#snippet formFields()}
<div class="grid gap-3 md:grid-cols-2">
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_type()}</span>
<select
name="relationType"
bind:value={addType}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
>
<optgroup label={m.relation_form_group_family()}>
<option value="PARENT_OF">{m.relation_parent_of()}</option>
<option value="SPOUSE_OF">{m.relation_spouse_of()}</option>
<option value="SIBLING_OF">{m.relation_sibling_of()}</option>
</optgroup>
<optgroup label={m.relation_form_group_social()}>
<option value="FRIEND">{m.relation_friend()}</option>
<option value="COLLEAGUE">{m.relation_colleague()}</option>
<option value="EMPLOYER">{m.relation_employer()}</option>
<option value="DOCTOR">{m.relation_doctor()}</option>
<option value="NEIGHBOR">{m.relation_neighbor()}</option>
<option value="OTHER">{m.relation_other()}</option>
</optgroup>
</select>
</label>
<div>
<PersonTypeahead
name="relatedPersonId"
label="Person"
bind:value={addRelatedPersonId}
initialName={addRelatedPersonName}
excludePersonId={personId}
compact
/>
</div>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2"
>{m.relation_form_field_from_year()}</span
>
<input
type="text"
name="fromYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addFromYear}
placeholder={m.relation_form_year_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
</label>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
>
<input
type="text"
name="toYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addToYear}
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
{#if yearError}
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
{yearError}
</p>
{/if}
</label>
</div>
{#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if}
{#if callbackError}
<p class="mt-2 text-xs text-red-700" role="alert">{callbackError}</p>
{/if}
<div class="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onclick={cancel}
class="rounded-sm border border-line bg-surface px-3 py-1.5 font-sans text-xs font-medium text-ink-2 transition hover:bg-muted"
>
{m.relation_btn_cancel()}
</button>
<button
type="submit"
disabled={submitDisabled}
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
>
{m.relation_btn_add()}
</button>
</div>
{/snippet}
{#if !open}
<button
type="button"
onclick={() => (open = true)}
class="mt-2 inline-flex items-center gap-1 font-sans text-xs font-medium text-ink-2 hover:text-ink"
>
{m.stammbaum_panel_add_rel()}
</button>
{:else if onSubmit}
<form onsubmit={handleCallbackSubmit} class="mt-3 rounded-sm border border-line bg-muted/40 p-3">
{@render formFields()}
</form>
{:else}
<form
method="POST"
action="?/addRelationship"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') {
open = false;
reset();
}
};
}}
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
>
{@render formFields()}
</form>
{/if}

View File

@@ -1,65 +0,0 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AddRelationshipForm from './AddRelationshipForm.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
afterEach(cleanup);
describe('AddRelationshipForm', () => {
it('shows add-relationship button initially and no form', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
await expect.element(page.getByRole('button')).toBeInTheDocument();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
});
it('shows relationType select when add button is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
it('hides form and shows button when cancel is clicked', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
);
cancelBtn!.click();
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
});
it('submit is disabled when no person is selected', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
});
it('form has no server action when onSubmit prop is provided', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const form = document.querySelector('form');
expect(form?.hasAttribute('action')).toBe(false);
});
it('shows year-range error when toYear is before fromYear', async () => {
render(AddRelationshipForm, { personId: 'person-1' });
document.querySelector<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
fromInput.value = '1935';
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
toInput.value = '1920';
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
await expect.element(page.getByRole('alert')).toBeVisible();
});
});

View File

@@ -18,8 +18,7 @@ let {
dimmed = false,
flashAnnotationId = null,
onDraw,
onAnnotationClick,
onDeleteRequest
onAnnotationClick
}: {
annotations: Annotation[];
canDraw: boolean;
@@ -30,7 +29,6 @@ let {
flashAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void;
onDeleteRequest?: (annotationId: string) => void;
} = $props();
let drawStart = $state<{ x: number; y: number } | null>(null);
@@ -114,8 +112,6 @@ const containerStyle = $derived(
dimmed={dimmed}
blockNumber={blockNumbers[annotation.id]}
isFlashing={flashAnnotationId === annotation.id}
showDelete={canDraw}
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
onclick={() => onAnnotationClick?.(annotation.id)}
onpointerenter={() => (hoveredId = annotation.id)}
onpointerleave={() => (hoveredId = null)}

View File

@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
expect(el2.style.opacity).toBe('1');
});
it('does not show delete button when annotation is not hovered or active', async () => {
it('does not show delete buttons (annotations owned by blocks)', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: true,
@@ -107,19 +107,6 @@ describe('AnnotationLayer', () => {
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when canDraw is false even if annotation is active', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: false,
color: '#00C7B1',
activeAnnotationId: 'ann-1',
onDraw: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
});
});

View File

@@ -11,8 +11,6 @@ let {
blockNumber = undefined,
isFlashing = false,
isResizable = false,
showDelete = false,
onDeleteRequest,
onclick,
onpointerenter,
onpointerleave
@@ -25,15 +23,11 @@ let {
blockNumber?: number | undefined;
isFlashing?: boolean;
isResizable?: boolean;
showDelete?: boolean;
onDeleteRequest?: () => void;
onclick: () => void;
onpointerenter: () => void;
onpointerleave: () => void;
} = $props();
const deleteVisible = $derived(showDelete && (isHovered || isActive));
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
@@ -89,7 +83,6 @@ let shapeStyle = $derived(
onclick={onclick}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick();
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
}}
onpointerenter={onpointerenter}
onpointerleave={onpointerleave}
@@ -119,51 +112,6 @@ let shapeStyle = $derived(
{blockNumber}
</div>
{/if}
{#if deleteVisible}
<button
data-testid="annotation-delete-{annotation.id}"
type="button"
aria-label="Löschen"
onclick={(e) => {
e.stopPropagation();
onDeleteRequest?.();
}}
style="
position: absolute;
top: 4px;
right: 4px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #fff;
border: 1px solid var(--color-error, #e53e3e);
color: var(--color-error, #e53e3e);
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
z-index: 10;
"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{#if isResizable}
<AnnotationEditOverlay annotation={annotation} />
{/if}

View File

@@ -1,177 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationShape from './AnnotationShape.svelte';
afterEach(cleanup);
function makeAnnotation(id = 'ann-1') {
return {
id,
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.2,
color: '#00C7B1',
createdAt: new Date().toISOString()
};
}
describe('AnnotationShape', () => {
it('renders the annotation element', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
});
it('does not show delete button when showDelete is false', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: false,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('shows delete button when showDelete is true and isHovered is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('shows delete button when showDelete is true and isActive is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('calls onDeleteRequest when delete button is clicked', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onclick when delete button is clicked', async () => {
const onclick = vi.fn();
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick,
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onclick).not.toHaveBeenCalled();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: false,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).not.toHaveBeenCalled();
});
});

View File

@@ -3,16 +3,9 @@ import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
import RelationshipPill from '$lib/components/RelationshipPill.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type GeschichteSummary = {
id: string;
title: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
};
type Props = {
documentDate: string | null;
@@ -21,43 +14,11 @@ type Props = {
sender: Person | null;
receivers: Person[];
tags: Tag[];
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
geschichten?: GeschichteSummary[];
documentId?: string;
canBlogWrite?: boolean;
};
let {
documentDate,
location,
status,
sender,
receivers,
tags,
inferredRelationship = null,
geschichten = [],
documentId,
canBlogWrite = false
}: Props = $props();
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
const VISIBLE_RECEIVER_LIMIT = 5;
const VISIBLE_GESCHICHTEN_LIMIT = 3;
const showGeschichtenColumn = $derived(geschichten.length > 0 || canBlogWrite);
const visibleGeschichten = $derived(geschichten.slice(0, VISIBLE_GESCHICHTEN_LIMIT));
const hasGeschichtenOverflow = $derived(geschichten.length >= VISIBLE_GESCHICHTEN_LIMIT);
const gridClass = $derived(showGeschichtenColumn ? 'lg:grid-cols-4' : 'lg:grid-cols-3');
function formatGeschichteAuthor(g: GeschichteSummary): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
function formatGeschichteDate(g: GeschichteSummary): string {
if (!g.publishedAt) return '';
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
const displayLocation = $derived(location ?? '—');
@@ -76,7 +37,7 @@ function getFullName(person: Person): string {
}
</script>
{#snippet personCard(person: Person, relationLabel: string | null = null)}
{#snippet personCard(person: Person)}
<a
href="/persons/{person.id}"
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
@@ -88,15 +49,12 @@ function getFullName(person: Person): string {
>
{getInitials(person.displayName)}
</span>
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
{#if relationLabel}
<RelationshipPill label={relationLabel} />
{/if}
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
</a>
{/snippet}
<div class="border-b border-line p-6">
<div class="grid grid-cols-1 gap-6 {gridClass}">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Column 1: Details -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
@@ -130,7 +88,7 @@ function getFullName(person: Person): string {
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_sender()}
</p>
{@render personCard(sender, inferredRelationship?.labelFromA ?? null)}
{@render personCard(sender)}
</div>
{/if}
{#if receivers.length > 0}
@@ -139,16 +97,8 @@ function getFullName(person: Person): string {
{m.doc_details_field_receivers()}
</p>
<div class="space-y-0.5">
{#each displayedReceivers as receiver, i (receiver.id)}
{@render personCard(
receiver,
// Badge only shown when there is exactly one receiver — with multiple
// receivers the inferred label is computed from the sender's viewpoint
// and cannot be attributed to a specific receiver.
i === 0 && receivers.length === 1
? (inferredRelationship?.labelFromB ?? null)
: null
)}
{#each displayedReceivers as receiver (receiver.id)}
{@render personCard(receiver)}
{/each}
</div>
{#if hiddenReceiverCount > 0 && !showAllReceivers}
@@ -188,51 +138,5 @@ function getFullName(person: Person): string {
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
{/if}
</div>
<!-- Column 4: Geschichten (visible when stories exist or user can author) -->
{#if showGeschichtenColumn}
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichten_card_heading()}
</h2>
{#if canBlogWrite && documentId}
<a
href="/geschichten/new?documentId={documentId}"
class="font-sans text-xs font-medium text-ink-2 hover:text-ink"
>
{m.geschichten_card_attach_action()}
</a>
{/if}
</div>
{#if geschichten.length === 0}
<p class="font-serif text-sm text-ink-3"></p>
{:else}
<ul class="space-y-2 font-serif text-sm">
{#each visibleGeschichten as g (g.id)}
<li>
<a href="/geschichten/{g.id}" class="block text-ink hover:underline">
{g.title}
</a>
<p class="font-sans text-xs text-ink-3">
{formatGeschichteAuthor(g)}
{#if formatGeschichteDate(g)}· {formatGeschichteDate(g)}{/if}
</p>
</li>
{/each}
</ul>
{#if hasGeschichtenOverflow && documentId}
<a
href="/geschichten?documentId={documentId}"
class="mt-3 inline-flex font-sans text-xs font-medium text-ink hover:underline"
>
{m.geschichten_card_show_all()}
</a>
{/if}
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -81,25 +81,6 @@ describe('DocumentMetadataDrawer — persons column', () => {
renderDrawer({ sender: null, receivers: [] });
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
});
it('renders inferred relationship pills inline next to sender and receiver', async () => {
renderDrawer({
receivers: [receivers[0]],
inferredRelationship: { labelFromA: 'Elternteil', labelFromB: 'Kind' }
});
// Sender link contains its pill, receiver link contains its pill.
const senderLink = page.getByRole('link', { name: /Karl Müller.*Elternteil/i });
await expect.element(senderLink).toBeInTheDocument();
const receiverLink = page.getByRole('link', { name: /Anna Schmidt.*Kind/i });
await expect.element(receiverLink).toBeInTheDocument();
});
it('omits the pills when no inferred relationship is provided', async () => {
renderDrawer();
const elternteil = page.getByText('Elternteil');
expect(await elternteil.elements()).toHaveLength(0);
});
});
// ─── Tags column ─────────────────────────────────────────────────────────────

View File

@@ -1,147 +0,0 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { formatDate } from '$lib/utils/date';
type Document = components['schemas']['Document'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
interface Props {
selectedDocuments?: Document[];
placeholder?: string;
hiddenInputName?: string;
}
let {
selectedDocuments = $bindable([]),
placeholder = m.geschichte_editor_search_document(),
hiddenInputName = 'documentIds'
}: Props = $props();
let searchTerm = $state('');
let results: Document[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) {
results = [];
return;
}
loading = true;
try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentSearchItem[] } = await res.json();
const docs = body.items.map((it) => it.document);
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {
results = [];
} finally {
loading = false;
}
}, 300);
}
function selectDocument(doc: Document) {
selectedDocuments = [...selectedDocuments, doc];
searchTerm = '';
showDropdown = false;
results = [];
}
function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
}
function formatDocLabel(doc: Document): string {
if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`;
return doc.title;
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedDocuments as doc (doc.id)}
<input type="hidden" name={hiddenInputName} value={doc.id} />
{/each}
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
>
{#each selectedDocuments as doc (doc.id)}
<span
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
>
{formatDocLabel(doc)}
<button
type="button"
onclick={() => removeDocument(doc.id)}
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
aria-label={m.comp_multiselect_remove()}
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
{/each}
<input
bind:this={inputEl}
type="text"
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => {
updateDropdownPosition();
showDropdown = true;
}}
placeholder={placeholder}
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
/>
</div>
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
>
{#if loading}
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
{:else}
{#each results as doc (doc.id)}
<div
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
onclick={() => selectDocument(doc)}
onkeydown={(e) => e.key === 'Enter' && selectDocument(doc)}
role="button"
tabindex="0"
>
{formatDocLabel(doc)}
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -1,126 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const docFactory = (id: string, title: string, date = '1880-01-01') => ({
id,
title,
documentDate: date,
originalFilename: `${title}.pdf`,
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) })
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('DocumentMultiSelect — rendering', () => {
it('renders an empty chip-input by default', async () => {
render(DocumentMultiSelect);
await expect.element(page.getByPlaceholder('Dokument suchen…')).toBeInTheDocument();
});
it('renders pre-selected documents as chips with their date', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Brief vom 1. Mai', '1882-05-01')]
});
await expect.element(page.getByText(/Brief vom 1\. Mai/)).toBeInTheDocument();
await expect.element(page.getByText(/01\.05\.1882/)).toBeInTheDocument();
});
it('emits a hidden documentIds input for each pre-selected document', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'A'), docFactory('d2', 'B')]
});
const inputs = document.querySelectorAll<HTMLInputElement>(
'input[type="hidden"][name="documentIds"]'
);
expect(inputs).toHaveLength(2);
expect([inputs[0].value, inputs[1].value].sort()).toEqual(['d1', 'd2']);
});
});
describe('DocumentMultiSelect — search and select', () => {
it('queries /api/documents/search after debounce and shows results', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringMatching(/^\/api\/documents\/search\?q=Eug/)
);
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
});
it('adds a chip when a search result is clicked', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
await userEvent.click(page.getByText(/Brief von Eugenie/));
// After selection the search field clears and the chip is rendered
const hidden = document.querySelector<HTMLInputElement>(
'input[type="hidden"][name="documentIds"]'
);
expect(hidden?.value).toBe('d1');
});
it('hides already-selected documents from new search results', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
items: [
{ document: docFactory('d1', 'Already attached') },
{ document: docFactory('d2', 'Not attached') }
]
})
});
vi.stubGlobal('fetch', fetchMock);
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Already attached')]
});
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'attached');
await waitForDebounce();
// "Not attached" appears in the dropdown; "Already attached" only as the chip.
const matches = await page.getByText(/Already attached/).all();
expect(matches.length).toBe(1); // chip only, not in dropdown
await expect.element(page.getByText(/Not attached/)).toBeInTheDocument();
});
});
describe('DocumentMultiSelect — remove', () => {
it('removes a chip when its × button is clicked', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Brief A')]
});
await userEvent.click(page.getByLabelText('Entfernen'));
expect(
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
).toBeNull();
});
});

View File

@@ -25,32 +25,14 @@ type Doc = {
tags?: Tag[] | null;
};
type GeschichteSummary = {
id: string;
title: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
};
type Props = {
doc: Doc;
canWrite: boolean;
fileUrl: string;
transcribeMode: boolean;
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
geschichten?: GeschichteSummary[];
canBlogWrite?: boolean;
};
let {
doc,
canWrite,
fileUrl,
transcribeMode = $bindable(),
inferredRelationship = null,
geschichten = [],
canBlogWrite = false
}: Props = $props();
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
let detailsOpen = $state(false);
@@ -293,10 +275,6 @@ let mobileMenuOpen = $state(false);
sender={doc.sender ?? null}
receivers={doc.receivers ? [...doc.receivers] : []}
tags={doc.tags ? [...doc.tags] : []}
inferredRelationship={inferredRelationship}
geschichten={geschichten}
documentId={doc.id}
canBlogWrite={canBlogWrite}
/>
</div>
{/if}

View File

@@ -24,7 +24,6 @@ type Props = {
flashAnnotationId?: string | null;
onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
};
let {
@@ -39,8 +38,7 @@ let {
annotationsDimmed = false,
flashAnnotationId = null,
onAnnotationClick,
onTranscriptionDraw,
onDeleteAnnotationRequest
onTranscriptionDraw
}: Props = $props();
</script>
@@ -100,7 +98,6 @@ let {
flashAnnotationId={flashAnnotationId}
onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw}
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}

View File

@@ -1,329 +0,0 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import PersonMultiSelect from './PersonMultiSelect.svelte';
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
type Geschichte = components['schemas']['Geschichte'];
type Person = components['schemas']['Person'];
type Document = components['schemas']['Document'];
interface Props {
geschichte?: Geschichte | null;
initialPersons?: Person[];
initialDocuments?: Document[];
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let {
geschichte = null,
initialPersons = [],
initialDocuments = [],
onSubmit,
submitting = false
}: Props = $props();
// Initial-state snapshot from incoming props. The editor owns these values
// after mount; the parent should re-mount the component with a different
// `geschichte` to reset (consistent with how form components in this codebase
// behave — see DocumentEdit page).
let title = $state(geschichte?.title ?? '');
let body = $state(geschichte?.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
let selectedPersons: Person[] = $state(
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
);
let selectedDocuments: Document[] = $state(
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
);
let dirty = $state(false);
let titleTouched = $state(false);
const titleEmpty = $derived(title.trim().length === 0);
const isDraft = $derived(status === 'DRAFT');
const showTitleError = $derived(titleEmpty && titleTouched);
let editorEl: HTMLDivElement;
let editor: Editor | null = null;
let toolbarVersion = $state(0);
onMount(() => {
editor = new Editor({
element: editorEl,
content: body,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
code: false,
codeBlock: false,
blockquote: false,
strike: false,
horizontalRule: false,
hardBreak: false
})
],
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.geschichte_editor_body_placeholder(),
class:
'prose max-w-none focus:outline-none min-h-[260px] font-serif text-base leading-relaxed'
}
},
onUpdate({ editor: ed }) {
body = ed.getHTML();
dirty = true;
},
onSelectionUpdate() {
toolbarVersion++;
},
onTransaction() {
toolbarVersion++;
}
});
});
onDestroy(() => {
editor?.destroy();
});
beforeNavigate(({ cancel }) => {
if (dirty && !submitting) {
const ok = window.confirm(m.geschichte_editor_unsaved_changes());
if (!ok) cancel();
}
});
function handleTitleBlur() {
titleTouched = true;
}
function handleTitleInput() {
dirty = true;
}
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
titleTouched = true;
if (titleEmpty) return;
await onSubmit({
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
});
dirty = false;
}
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
void toolbarVersion;
return editor?.isActive(name, attrs) ?? false;
}
function exec(action: () => void) {
if (!editor) return;
action();
editor.commands.focus();
}
</script>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Editor column -->
<div class="flex flex-col gap-4">
<!-- Title -->
<div>
<input
type="text"
bind:value={title}
oninput={handleTitleInput}
onblur={handleTitleBlur}
placeholder={m.geschichte_editor_title_placeholder()}
aria-invalid={showTitleError}
aria-describedby={showTitleError ? 'title-error' : undefined}
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if showTitleError}
<p id="title-error" class="mt-1 text-sm text-danger" role="alert">
{m.geschichte_editor_title_required()}
</p>
{/if}
</div>
<!-- Toolbar -->
<div
role="toolbar"
aria-label="Formatierung"
class="flex flex-wrap items-center gap-1 rounded border border-line bg-surface p-1"
>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleBold().run())}
aria-label={m.geschichte_editor_toolbar_bold()}
aria-pressed={isActive('bold')}
title={m.geschichte_editor_toolbar_bold()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
B
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleItalic().run())}
aria-label={m.geschichte_editor_toolbar_italic()}
aria-pressed={isActive('italic')}
title={m.geschichte_editor_toolbar_italic()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink italic hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
I
</button>
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 2 }).run())}
aria-label={m.geschichte_editor_toolbar_h2()}
aria-pressed={isActive('heading', { level: 2 })}
title={m.geschichte_editor_toolbar_h2()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
H2
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 3 }).run())}
aria-label={m.geschichte_editor_toolbar_h3()}
aria-pressed={isActive('heading', { level: 3 })}
title={m.geschichte_editor_toolbar_h3()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
H3
</button>
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleBulletList().run())}
aria-label={m.geschichte_editor_toolbar_ul()}
aria-pressed={isActive('bulletList')}
title={m.geschichte_editor_toolbar_ul()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleOrderedList().run())}
aria-label={m.geschichte_editor_toolbar_ol()}
aria-pressed={isActive('orderedList')}
title={m.geschichte_editor_toolbar_ol()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
1.
</button>
</div>
<!-- Editor surface -->
<div
class="rounded border border-line bg-surface p-4 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring"
>
<div bind:this={editorEl} class="min-h-[260px]"></div>
</div>
</div>
<!-- Sidebar -->
<aside class="flex flex-col gap-6">
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
<p class="mb-3">
<span
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
? 'bg-muted text-ink-2'
: 'bg-accent-bg text-ink'}"
>
{isDraft
? m.geschichte_editor_status_draft()
: m.geschichte_editor_status_published()}
</span>
</p>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_status_draft_hint()
: m.geschichte_editor_status_published_hint()}
</p>
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_personen_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_dokumente_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
</section>
</aside>
</div>
<!-- Save bar -->
<div
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_save_hint_draft()
: m.geschichte_editor_save_hint_published()}
</p>
<div class="flex gap-2">
{#if isDraft}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save_draft()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_publish()}
</button>
{:else}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_unpublish()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save()}
</button>
{/if}
</div>
</div>

View File

@@ -1,150 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import GeschichteEditor from './GeschichteEditor.svelte';
const personFactory = (id: string, displayName: string) => ({
id,
firstName: displayName.split(' ')[0],
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
displayName,
personType: 'PERSON' as const
});
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
id,
title,
documentDate: date,
originalFilename: `${title}.pdf`,
status: 'UPLOADED' as const,
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Existing draft',
body: '<p>Hello world</p>',
status: 'DRAFT' as const,
persons: [],
documents: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
});
afterEach(() => cleanup());
describe('GeschichteEditor — title-required guard', () => {
it('disables both DRAFT save buttons when the title is empty', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
const draft = await page.getByRole('button', { name: 'Entwurf speichern' }).element();
const publish = await page.getByRole('button', { name: 'Veröffentlichen' }).element();
expect(draft).toHaveProperty('disabled', true);
expect(publish).toHaveProperty('disabled', true);
});
it('shows the inline error after the title field is blurred while empty', async () => {
const onSubmit = vi.fn();
render(GeschichteEditor, { onSubmit });
await userEvent.click(page.getByPlaceholder('Titel der Geschichte'));
await userEvent.tab(); // blur
await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument();
});
});
describe('GeschichteEditor — save bar adapts to status', () => {
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() });
await expect
.element(page.getByRole('button', { name: 'Entwurf speichern' }))
.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Veröffentlichen' })).toBeInTheDocument();
});
it('renders PUBLISHED mode buttons when geschichte.status is PUBLISHED', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ status: 'PUBLISHED', publishedAt: '2024-04-01T12:00:00' }),
onSubmit: vi.fn()
});
await expect.element(page.getByRole('button', { name: 'Speichern' })).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Zurück zu Entwurf' }))
.toBeInTheDocument();
});
});
describe('GeschichteEditor — pre-fill', () => {
it('renders initial persons as chips', async () => {
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
onSubmit: vi.fn()
});
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
});
it('renders initial documents as chips', async () => {
render(GeschichteEditor, {
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
onSubmit: vi.fn()
});
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
});
it('populates the title input from a geschichte prop', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ title: 'My existing story' }),
onSubmit: vi.fn()
});
const input = await page.getByPlaceholder('Titel der Geschichte').element();
expect((input as HTMLInputElement).value).toBe('My existing story');
});
});
describe('GeschichteEditor — onSubmit payload', () => {
it('passes the trimmed title and DRAFT status when "Entwurf speichern" is clicked', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), ' My title ');
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.title).toBe('My title');
expect(payload.status).toBe('DRAFT');
});
it('passes status=PUBLISHED when "Veröffentlichen" is clicked', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
await userEvent.click(page.getByRole('button', { name: 'Veröffentlichen' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
});
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
initialDocuments: [docFactory('d1', 'Brief A')],
onSubmit
});
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.personIds).toEqual(['p1']);
expect(payload.documentIds).toEqual(['d1']);
});
});

View File

@@ -1,89 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import { plainExcerpt } from '$lib/utils/extractText';
import { formatDate } from '$lib/utils/date';
type Geschichte = components['schemas']['Geschichte'];
interface Props {
geschichten: Geschichte[];
personId: string;
personName: string;
canWrite: boolean;
}
let { geschichten, personId, personName, canWrite }: Props = $props();
const visible = $derived(geschichten.slice(0, 3));
const hasOverflow = $derived(geschichten.length >= 3);
function formatPublishedDate(g: Geschichte): string | null {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
function authorName(g: Geschichte): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
</script>
{#if geschichten.length > 0}
<section
aria-labelledby="geschichten-card-heading"
class="rounded-sm border border-line bg-surface p-6 shadow-sm"
>
<header class="mb-5 flex items-center justify-between">
<h2
id="geschichten-card-heading"
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.geschichten_card_heading()}
</h2>
{#if canWrite}
<a
href="/geschichten/new?personId={personId}"
class="inline-flex items-center font-sans text-sm font-medium text-ink-2 hover:text-ink"
>
{m.geschichten_card_write_action()}
</a>
{/if}
</header>
<ul class="-mx-2">
{#each visible as g (g.id)}
<li>
<a
href="/geschichten/{g.id}"
class="group flex flex-col gap-1 border-b border-line px-2 py-3 transition-colors last:border-b-0 hover:bg-muted"
>
<span class="font-serif text-base font-bold text-ink group-hover:underline">
{g.title}
</span>
<span class="font-sans text-xs text-ink-3">
{authorName(g)}
{#if formatPublishedDate(g)}· {formatPublishedDate(g)}{/if}
</span>
{#if g.body}
<span class="font-serif text-sm text-ink-2">{plainExcerpt(g.body, 80)}</span>
{/if}
</a>
</li>
{/each}
</ul>
{#if hasOverflow}
<footer class="mt-4 border-t border-line pt-3">
<a
href="/geschichten?personId={personId}"
class="inline-flex items-center font-sans text-sm font-medium text-ink hover:underline"
>
{m.geschichten_card_show_all_for_person({ name: personName })}
</a>
</footer>
{/if}
</section>
{/if}

View File

@@ -1,140 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
id,
title,
body,
status: 'PUBLISHED' as const,
publishedAt: '2024-04-01T12:00:00',
createdAt: '2024-03-01T12:00:00',
updatedAt: '2024-04-01T12:00:00',
persons: [],
documents: [],
author: {
id: 'u1',
email: 'marcel@example.com',
firstName: 'Marcel',
lastName: 'Raddatz',
enabled: true,
notifyOnReply: false,
notifyOnMention: false,
groups: [],
createdAt: '2024-01-01T00:00:00',
color: '#000'
}
});
afterEach(() => cleanup());
describe('GeschichtenCard', () => {
it('renders nothing when geschichten is empty', async () => {
render(GeschichtenCard, {
geschichten: [],
personId: 'p1',
personName: 'Franz',
canWrite: true
});
// No heading, no list — the entire <section> should not exist
expect(
document.querySelector('section[aria-labelledby="geschichten-card-heading"]')
).toBeNull();
});
it('renders the section heading and stories when geschichten is non-empty', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'Erinnerung an Franz')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
await expect.element(page.getByText('Geschichten')).toBeInTheDocument();
// The whole row is one link to the story; matching on the title text via
// a partial regex tolerates trailing author/date metadata in the
// accessible name.
const link = await page
.getByRole('link', { name: /Erinnerung an Franz/ })
.first()
.element();
expect(link.getAttribute('href')).toBe('/geschichten/g1');
});
it('makes the entire story row a single clickable link', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A title', '<p>Some body excerpt text</p>')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
// The body-excerpt text is inside the same <a> as the title.
const links = await page.getByRole('link', { name: /A title/ }).all();
expect(links.length).toBeGreaterThan(0);
const linkEl = await links[0].element();
expect(linkEl.tagName).toBe('A');
expect(linkEl.textContent).toContain('Some body excerpt text');
});
it('hides the "+ Geschichte schreiben" link when canWrite is false', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A story')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
const writeLinks = await page.getByText(/Geschichte schreiben/).all();
expect(writeLinks).toHaveLength(0);
});
it('shows the write-action link only when canWrite is true', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A story')],
personId: 'p1',
personName: 'Franz',
canWrite: true
});
const link = await page.getByRole('link', { name: /Geschichte schreiben/ }).element();
expect(link.getAttribute('href')).toBe('/geschichten/new?personId=p1');
});
it('hides the "Alle Geschichten zu …" footer link below the 3-story threshold', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
const overflow = await page.getByText(/Alle Geschichten zu/).all();
expect(overflow).toHaveLength(0);
});
it('shows the footer link at the 3-story threshold (>= 3)', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B'), makeStory('g3', 'C')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
const link = await page.getByRole('link', { name: /Alle Geschichten zu Franz/ }).element();
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
});
it('renders a plain-text excerpt without HTML markup', async () => {
render(GeschichtenCard, {
geschichten: [
makeStory(
'g1',
'Mit HTML',
'<p>Plain <strong>bold</strong> story</p><script>alert(1)</script>'
)
],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
// Body excerpt appears once as plain text — no <strong> rendered, no script
await expect.element(page.getByText(/Plain bold story/)).toBeInTheDocument();
expect(document.body.innerHTML).not.toContain('<script>');
});
});

View File

@@ -1,170 +0,0 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
// The dropdown receives a single reactive state object. PersonMentionEditor
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
// proxy reactivity propagates the change here. This is the supported way to
// update an imperatively-mounted Svelte 5 component — `mount` does not return
// settable prop accessors.
type DropdownState = {
items: Person[];
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
};
let { model }: { model: DropdownState } = $props();
// highlightedIndex must be both writable (keyboard handler mutates it) and
// reset when `items` changes (so it never points past the end of a new list).
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
// is the correct pattern here. The autofixer suggestion to use $derived does not
// apply: the mutation in onKeyDown is not a derivation.
let highlightedIndex = $state(0);
$effect(() => {
// Read model.items to subscribe; reset index whenever the list is replaced.
void model.items;
highlightedIndex = 0;
});
// ---------------------------------------------------------------------------
// Positioning — flip strategy: open upward when there is not enough room
// below the cursor to show the dropdown without clipping the viewport.
// ---------------------------------------------------------------------------
type Position = {
top: string | null;
bottom: string | null;
left: string;
};
const DROPDOWN_CLEARANCE_PX = 200;
const position = $derived.by<Position>(() => {
const cr = model.clientRect;
if (!cr) return { top: '0px', bottom: null, left: '0px' };
const rect = cr();
if (!rect) return { top: '0px', bottom: null, left: '0px' };
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
// Not enough space below — anchor bottom of dropdown to top of caret.
return {
top: null,
bottom: `${window.innerHeight - rect.top}px`,
left
};
}
return { top: `${rect.bottom}px`, bottom: null, left };
});
// ---------------------------------------------------------------------------
// Keyboard handler — exported so Tiptap's render() can forward events.
// Returns true when the event is consumed (prevents the editor's default).
// ---------------------------------------------------------------------------
export function onKeyDown(event: KeyboardEvent): boolean {
const len = model.items.length;
if (event.key === 'ArrowDown') {
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
return true;
}
if (event.key === 'ArrowUp') {
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
return true;
}
if (event.key === 'Enter') {
const selected = model.items[highlightedIndex];
if (selected) {
model.command(selected);
}
return true;
}
// Escape: let the suggestion plugin handle it (return false = not consumed).
return false;
}
function selectItem(item: Person) {
model.command(item);
}
</script>
<!--
Mounted imperatively to document.body by the Tiptap suggestion plugin.
Positioned absolutely relative to the viewport using inline styles derived
from the Tiptap clientRect() callback.
SECURITY: This component receives pre-filtered Person[] items from the
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
proxy injecting the auth_token cookie as the Authorization header.
Mounted in transcribe mode behind WRITE_ALL — never reachable to
unauthenticated users.
-->
<div
class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.person_mention_btn_label()}
style:top={position.top}
style:bottom={position.bottom}
style:left={position.left}
>
{#if model.items.length === 0}
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
{m.person_mention_popup_empty()}
</p>
<!--
Empty-state escape hatch — without it the transcriber has to close
the dropdown, navigate to /persons/new, come back, and re-type the
query. target=_blank keeps the document and editor state intact;
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
-->
<a
href="/persons/new"
target="_blank"
rel="noopener"
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
onmousedown={(e) => e.preventDefault()}
>
{m.person_mention_create_new()}
<span aria-hidden="true"></span>
</a>
{:else}
{#each model.items as person, i (person.id)}
<div
class={[
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
]}
role="option"
aria-selected={i === highlightedIndex}
data-test-person-id={person.id}
tabindex="-1"
onmousedown={(e) => {
// Prevent blur on the editor before the selection fires.
e.preventDefault();
selectItem(person);
}}
>
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
<span class="truncate font-sans text-xs text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</span>
{/if}
</div>
{/each}
{/if}
</div>

View File

@@ -48,12 +48,6 @@ function handleKeydown(event: KeyboardEvent) {
}
}
const bellLabel = $derived(
stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()
);
function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node;
return () => {
@@ -78,11 +72,12 @@ onDestroy(() => {
{@attach attachBellButton}
type="button"
onclick={toggleDropdown}
aria-label={bellLabel}
title={bellLabel}
aria-label={stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()}
aria-expanded={open}
aria-haspopup="true"
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -55,34 +55,6 @@ async function openDropdownAndClickFirstNotification() {
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.classList.contains('cursor-pointer')).toBe(true);
});
it('bell button title equals aria-label when unreadCount is 0', async () => {
mockNotificationList.value = [];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
it('bell button title equals aria-label when unreadCount is 3', async () => {
mockNotificationList.value = [
makeNotification({ id: 'n1' }),
makeNotification({ id: 'n2' }),
makeNotification({ id: 'n3' })
];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
});
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];

View File

@@ -20,48 +20,6 @@ const controlBase =
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
const activePageBase =
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white';
/**
* Builds the sliding window of 1-indexed page numbers to render as buttons.
* Always shows: first, last, current, one neighbor each side.
* null entries represent ellipsis gaps.
*/
const pageWindow = $derived.by(() => {
const first = 1;
const last = totalPages;
const current = page + 1; // convert to 1-indexed
const windowStart = Math.max(first, current - 1);
const windowEnd = Math.min(last, current + 1);
const result: (number | null)[] = [];
result.push(first);
if (windowStart > first + 2) {
result.push(null); // left ellipsis
} else if (windowStart === first + 2) {
result.push(first + 1); // bridge: one page gap, show directly instead of ellipsis
}
for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) {
result.push(p);
}
if (windowEnd < last - 2) {
result.push(null); // right ellipsis
} else if (windowEnd === last - 2) {
result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis
}
if (last > first) {
result.push(last);
}
return result;
});
</script>
{#if totalPages > 1}
@@ -94,60 +52,13 @@ const pageWindow = $derived.by(() => {
</span>
{/if}
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
<!-- aria-hidden: decorative visual label; AT uses the sr-only span below for aria-current -->
<span
data-testid="pagination-page-label"
aria-hidden="true"
class="font-sans text-sm text-ink-2 sm:hidden"
aria-current="page"
class="font-sans text-sm text-ink-2"
>
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
<!-- Always in the AT tree: announces current page regardless of breakpoint.
On mobile, the desktop button container is display:none so this is the only AT anchor.
On desktop, the active page button also carries aria-current — both announce the same info. -->
<span data-testid="pagination-current-page-sr" aria-current="page" class="sr-only">
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
<!-- Desktop: numbered page buttons (hidden below sm:) -->
<div data-testid="pagination-pages" class="hidden items-center gap-1 sm:flex">
{#each pageWindow as entry, i (entry === null ? 'ellipsis-' + i : entry)}
{#if entry === null}
{#if i === 1}
<span
data-testid="pagination-ellipsis-left"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{:else}
<span
data-testid="pagination-ellipsis-right"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{/if}
{:else if entry === page + 1}
<span
data-testid="pagination-page-{entry}"
aria-current="page"
aria-label={m.pagination_page_button({ page: entry })}
class={activePageBase}
>
{entry}
</span>
{:else}
<a
data-testid="pagination-page-{entry}"
aria-label={m.pagination_page_button({ page: entry })}
href={makeHref(entry - 1)}
class={linkBase}
>
{entry}
</a>
{/if}
{/each}
</div>
{#if hasNext}
<a

View File

@@ -19,145 +19,11 @@ describe('Pagination', () => {
await expect.element(label).toHaveTextContent(/10/);
});
it('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => {
it('marks the current page label with aria-current="page"', async () => {
render(Pagination, { page: 0, totalPages: 3, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
});
describe('page number buttons', () => {
it('renders page number buttons when totalPages > 1', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
// active page button — the current page (5, 1-indexed)
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toBeInTheDocument();
});
it('does not render page number buttons when totalPages <= 1', async () => {
render(Pagination, { page: 0, totalPages: 1, makeHref });
// entire nav is hidden
const nav = page.getByRole('navigation');
await expect.element(nav).not.toBeInTheDocument();
});
it('marks the active page button with aria-current="page"', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveAttribute('aria-current', 'page');
});
it('active page button has brand-navy background', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveClass(/bg-brand-navy/);
});
it('active page button has 44px touch target', async () => {
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const activeBtn = nav.getByTestId('pagination-page-5');
await expect.element(activeBtn).toHaveClass(/min-h-\[44px\]/);
await expect.element(activeBtn).toHaveClass(/min-w-\[44px\]/);
});
it('inactive page buttons link to their target page via makeHref', async () => {
const spy = vi.fn(makeHref);
render(Pagination, { page: 4, totalPages: 12, makeHref: spy });
const nav = page.getByRole('navigation');
// page button for page 1 (0-indexed: 0) should link to /documents?page=0
const firstPageBtn = nav.getByTestId('pagination-page-1');
await expect.element(firstPageBtn).toHaveAttribute('href', '/documents?page=0');
});
it('renders first and last page buttons always visible', async () => {
render(Pagination, { page: 5, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-page-12')).toBeInTheDocument();
});
it('renders ellipsis span between first page and window when gap exists', async () => {
// page 6 (0-indexed: 5) — window is 5,6,7 — gap between 1 and 5
render(Pagination, { page: 5, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const ellipses = nav.getByTestId('pagination-ellipsis-left');
await expect.element(ellipses).toBeInTheDocument();
});
it('renders ellipsis span between window and last page when gap exists', async () => {
// page 1 (0-indexed: 0) — window is 1,2 — gap between 2 and 12
render(Pagination, { page: 0, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const ellipsis = nav.getByTestId('pagination-ellipsis-right');
await expect.element(ellipsis).toBeInTheDocument();
});
it('does not render left ellipsis when window is adjacent to first page', async () => {
// page 1 (0-indexed: 0) — window starts at 1, adjacent to first page
render(Pagination, { page: 0, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const leftEllipsis = nav.getByTestId('pagination-ellipsis-left');
await expect.element(leftEllipsis).not.toBeInTheDocument();
});
it('does not render right ellipsis when window is adjacent to last page', async () => {
// last page (0-indexed: 11) — window ends at 12, adjacent to last page
render(Pagination, { page: 11, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const rightEllipsis = nav.getByTestId('pagination-ellipsis-right');
await expect.element(rightEllipsis).not.toBeInTheDocument();
});
it('page buttons container has hidden class on mobile (sm: prefix)', async () => {
// The page buttons container must be hidden below sm: breakpoint
render(Pagination, { page: 4, totalPages: 12, makeHref });
const nav = page.getByRole('navigation');
const pageButtons = nav.getByTestId('pagination-pages');
await expect.element(pageButtons).toHaveClass(/hidden/);
await expect.element(pageButtons).toHaveClass(/sm:flex/);
});
it('renders both pages without ellipsis when totalPages is 2', async () => {
render(Pagination, { page: 0, totalPages: 2, makeHref });
const nav = page.getByRole('navigation');
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-page-2')).toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-ellipsis-left')).not.toBeInTheDocument();
await expect.element(nav.getByTestId('pagination-ellipsis-right')).not.toBeInTheDocument();
});
});
it('mobile page label is aria-hidden so screen readers skip it on wide screens', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
});
it('sr-only span always provides aria-current="page" for screen readers at all breakpoints', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const nav = page.getByRole('navigation');
const srLabel = nav.getByTestId('pagination-current-page-sr');
await expect.element(srLabel).toBeInTheDocument();
await expect.element(srLabel).toHaveAttribute('aria-current', 'page');
await expect.element(label).toHaveAttribute('aria-current', 'page');
});
it('renders prev as a link pointing at page - 1 when not on first page', async () => {

View File

@@ -18,7 +18,6 @@ let {
activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick,
onTranscriptionDraw,
onDeleteAnnotationRequest,
documentFileHash,
annotationsDimmed = false,
flashAnnotationId = null
@@ -31,7 +30,6 @@ let {
activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
documentFileHash?: string | null;
annotationsDimmed?: boolean;
flashAnnotationId?: string | null;
@@ -266,7 +264,6 @@ function handleAnnotationClick(id: string) {
flashAnnotationId={flashAnnotationId}
onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick}
onDeleteRequest={onDeleteAnnotationRequest}
/>
{/if}
</div>

View File

@@ -1,303 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import { chipLabel, otherName } from '$lib/relationshipLabels';
import type { components } from '$lib/generated/api';
import type { LoadState } from '$lib/types/personHoverCard';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type Props = {
personId: string;
cardId: string;
position: { top: number; left: number };
state: LoadState;
onmouseenter?: () => void;
onmouseleave?: () => void;
};
let { personId, cardId, position, state, onmouseenter, onmouseleave }: Props = $props();
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
'PARENT_OF',
'SPOUSE_OF',
'SIBLING_OF'
]);
const NOTES_MAX = 120;
const familyChips = $derived(
state.status === 'loaded'
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
: []
);
const dateRange = $derived(
state.status === 'loaded'
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
: ''
);
/**
* Cut the notes excerpt at the last word boundary inside the NOTES_MAX
* window. Mid-word truncation is especially ugly in German compound nouns
* ("…Familienzu…"), so prefer the previous space if there is one within
* a reasonable distance. Fall back to a hard cut for strings with no
* spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
*/
function truncateAtWordBoundary(text: string, max: number): string {
if (text.length <= max) return text;
const window = text.slice(0, max);
const lastSpace = window.lastIndexOf(' ');
// If the last space is too close to the start (< 70% of the window) we'd
// produce a near-empty excerpt — fall back to the hard cut instead.
const minBoundary = Math.floor(max * 0.7);
const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
return cut + '…';
}
const notesExcerpt = $derived.by(() => {
if (state.status !== 'loaded') return null;
const notes = state.person.notes;
if (!notes) return null;
return truncateAtWordBoundary(notes, NOTES_MAX);
});
// Accessible name for the region landmark — required by WCAG 1.3.1.
// Falls back to a localised loading label so axe-core never sees an unnamed
// region (Leonie FINDING-02 / Elicit NFR concern).
const ariaLabel = $derived(
state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()
);
// aria-busy="true" while loading so SR clients know the region's contents
// will change. Cleared on loaded/error so the new content is announced.
const ariaBusy = $derived(state.status === 'loading');
const showMaidenName = $derived(
state.status === 'loaded' &&
!!state.person.alias &&
state.person.alias !== state.person.lastName &&
state.person.alias !== state.person.displayName
);
</script>
<div
class="person-hover-card"
data-testid="person-hover-card"
id={cardId}
role="region"
aria-live="polite"
aria-label={ariaLabel}
aria-busy={ariaBusy ? 'true' : undefined}
style:position="fixed"
style:top={`${position.top}px`}
style:left={`${position.left}px`}
onmouseenter={onmouseenter}
onmouseleave={onmouseleave}
onfocusin={onmouseenter}
onfocusout={(e) => {
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
onmouseleave?.();
}
}}
>
{#if state.status === 'loading'}
<div
data-testid="person-hover-card-skeleton"
class="skeleton"
role="status"
aria-label={m.person_mention_loading()}
>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
{:else if state.status === 'error'}
<div data-testid="person-hover-card-error" class="error-message">
{m.person_mention_load_error()}
</div>
<div class="footer">
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()}</a>
</div>
{:else}
<div data-testid="person-hover-card-content" class="content">
<div class="header">
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
{#if dateRange}
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
{/if}
{#if showMaidenName}
<div class="maiden" data-testid="person-hover-card-maiden">
{m.person_born_name_prefix()}
{state.person.alias}
</div>
{/if}
</div>
{#if familyChips.length > 0}
<div class="chips" data-testid="person-hover-card-chips">
{#each familyChips as chip (chip.id)}
<span class="chip">
<span class="chip-type">{chipLabel(chip, personId)}:</span>
{otherName(chip, personId)}
</span>
{/each}
</div>
{/if}
{#if notesExcerpt}
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
{/if}
<div class="footer">
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()}</a>
<span class="hint">{m.person_mention_hover_hint()}</span>
</div>
</div>
{/if}
</div>
<style>
.person-hover-card {
width: 320px;
min-height: 180px;
background-color: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 14px 16px;
font-family: var(--font-sans);
font-size: 14px;
color: var(--c-ink);
z-index: 50;
}
/* On touch devices the card is suppressed entirely — tap navigates directly. */
@media (hover: none) {
.person-hover-card {
display: none;
}
}
.skeleton {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0;
}
.skeleton .bar {
height: 14px;
border-radius: 4px;
background-color: var(--c-line);
animation: pulse 1.4s ease-in-out infinite;
}
.skeleton .bar:nth-child(1) {
width: 60%;
}
.skeleton .bar:nth-child(2) {
width: 40%;
}
.skeleton .bar:nth-child(3) {
width: 90%;
}
@keyframes pulse {
0% {
opacity: 0.55;
}
50% {
opacity: 1;
}
100% {
opacity: 0.55;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton .bar {
animation: none;
opacity: 0.7;
}
}
.header {
display: flex;
flex-direction: column;
gap: 2px;
background-color: var(--c-ink);
color: var(--c-surface);
margin: -14px -16px 12px;
padding: 12px 16px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.name {
font-family: var(--font-serif);
font-weight: 600;
font-size: 16px;
}
.dates,
.maiden {
font-size: 12px;
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.chip {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
background-color: var(--c-accent-bg);
color: var(--c-ink);
border-radius: 999px;
padding: 2px 10px;
}
.chip-type {
font-weight: 600;
/* opacity 0.7 on --c-ink: ~5.6:1 light, ~7.1:1 dark — WCAG AA ✓ */
opacity: 0.7;
}
.notes {
font-size: 13px;
color: var(--c-ink-2);
line-height: 1.4;
margin: 0 0 10px;
}
.error-message {
font-size: 13px;
color: var(--c-ink-2);
padding: 8px 0;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--c-line);
padding-top: 8px;
margin-top: 4px;
}
.open-link {
color: var(--c-ink);
text-decoration: underline;
text-underline-offset: 3px;
font-weight: 500;
}
.hint {
font-size: 11px;
color: var(--c-ink-3);
}
</style>

View File

@@ -1,373 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonHoverCard from './PersonHoverCard.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
const AUGUSTE: Person = {
id: 'p-aug',
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: true,
birthYear: 1882,
deathYear: 1944
} as unknown as Person;
const POSITION = { top: 100, left: 200 };
afterEach(() => cleanup());
describe('PersonHoverCard — loading state', () => {
it('shows the skeleton when state.status is loading', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
});
it('renders three skeleton bars', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
expect(bars.length).toBe(3);
});
});
describe('PersonHoverCard — error state', () => {
it('shows a generic error message when state.status is error', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'error' }
});
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
});
it('still allows the link footer to navigate (link present in error state)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'error' }
});
// The card root must show the footer link even when the body errored —
// click navigation works regardless of fetch outcome.
const link = document.querySelector('a[href="/persons/p-aug"]');
expect(link).not.toBeNull();
});
});
describe('PersonHoverCard — loaded state', () => {
it('renders the person displayName as the header name', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
it('renders the life-date range when birthYear and deathYear are present', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument();
});
it('omits the life-date line when both years are missing', async () => {
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: noDates, relationships: [] }
});
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
expect(dates).toBeNull();
});
it('renders "geb. <alias>" when alias is set', async () => {
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withAlias, relationships: [] }
});
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
});
it('omits the maiden name line when alias is null', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
expect(maiden).toBeNull();
});
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
const relationships: RelationshipDTO[] = [
{
id: 'r1',
personId: 'p-aug',
relatedPersonId: 'p-spouse',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Otto Raddatz',
relationType: 'SPOUSE_OF'
},
{
id: 'r2',
personId: 'p-aug',
relatedPersonId: 'p-friend',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND'
},
{
id: 'r3',
personId: 'p-aug',
relatedPersonId: 'p-sibling',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Marie Sister',
relationType: 'SIBLING_OF'
}
];
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships }
});
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
// Non-family relationship type must be filtered out
const friendChip = page.getByText('Karl Friend');
await expect.element(friendChip).not.toBeInTheDocument();
});
it('shows the other person name when hovered person is the object (relatedPersonId) in a PARENT_OF row', async () => {
// Storage: Heinrich PARENT_OF Auguste. When viewing Auguste's card,
// the chip must show "Heinrich" (the parent), not "Auguste" (herself).
const relationships: RelationshipDTO[] = [
{
id: 'r-parent',
personId: 'p-heinrich',
relatedPersonId: 'p-aug',
personDisplayName: 'Heinrich Raddatz',
relatedPersonDisplayName: 'Auguste Raddatz',
relationType: 'PARENT_OF'
}
];
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships }
});
await expect.element(page.getByText('Heinrich Raddatz')).toBeInTheDocument();
// Auguste must NOT appear as her own parent chip name
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
expect(chips?.textContent).not.toContain('Auguste Raddatz');
});
it('omits the chips section entirely when no family relationships', async () => {
const onlyFriend: RelationshipDTO[] = [
{
id: 'r1',
personId: 'p-aug',
relatedPersonId: 'p-friend',
personDisplayName: 'Auguste',
relatedPersonDisplayName: 'Karl Friend',
relationType: 'FRIEND'
}
];
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
});
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
expect(chips).toBeNull();
});
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withNotes, relationships: [] }
});
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
});
it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => {
// Single 150-char word with no spaces: word-boundary cut would yield nothing,
// so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length).
const long = 'x'.repeat(150);
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withLongNotes, relationships: [] }
});
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
expect(notes.textContent).toBe('x'.repeat(120) + '…');
});
it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => {
// 150-char string with spaces — must cut at the last space, not mid-word.
const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3);
// length is 180, last space at idx ≤120
const withLongNotes = { ...AUGUSTE, notes: sentence } as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: withLongNotes, relationships: [] }
});
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
const text = notes.textContent ?? '';
// Ends with ellipsis
expect(text.endsWith('…')).toBe(true);
// Last char before the ellipsis is NOT a half-word — verify by checking that
// the position right before … is the end of a word (i.e., there's no letter
// further along in the original text immediately after our cut point).
const cut = text.slice(0, -1); // strip the …
// Find this cut substring in the original sentence
const idx = sentence.indexOf(cut);
expect(idx).toBe(0);
const charAfterCut = sentence[cut.length];
// The next char should be a space — confirming we cut on a boundary
expect(charAfterCut).toBe(' ');
});
it('omits notes section when notes is null', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
expect(notes).toBeNull();
});
it('footer renders an anchor link to /persons/{personId}', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const link = document.querySelector('a[href="/persons/p-aug"]')!;
expect(link).not.toBeNull();
});
});
describe('PersonHoverCard — accessibility', () => {
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.getAttribute('aria-live')).toBe('polite');
});
it('sets aria-busy="true" while loading so SR announces the state change on load', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.getAttribute('aria-busy')).toBe('true');
});
it('does not set aria-busy when loaded (so the loaded content is announced)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
// aria-busy is either absent or "false"
const busy = root.getAttribute('aria-busy');
expect(busy === null || busy === 'false').toBe(true);
});
it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz');
});
it('names the region with a generic loading label while loading', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
// Region must have an accessible name in every state — axe-core flags
// role="region" without aria-label / aria-labelledby.
expect(root.getAttribute('aria-label')).toBeTruthy();
});
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-xyz',
position: POSITION,
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]')!;
expect(root.id).toBe('card-xyz');
});
it('positions itself fixed at the given top/left', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: { top: 333, left: 444 },
state: { status: 'loading' }
});
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
expect(root.style.top).toBe('333px');
expect(root.style.left).toBe('444px');
expect(root.style.position).toBe('fixed');
});
});

Some files were not shown because too many files have changed in this diff Show More