From 315b368f880e277cf6c95e8797e6b5959e984b3c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 11:29:41 +0100 Subject: [PATCH 01/13] feat: add DocumentVersion entity, repository, service, and migration Creates the document_versions table (V9) with JSONB snapshot and changed_fields columns. DocumentVersionService records a version on every create/update, resolves the editor name from the security context, and computes changedFields by diffing against the previous snapshot. Refs #38 Co-Authored-By: Claude Sonnet 4.6 --- .../dto/DocumentVersionSummary.java | 14 + .../familienarchiv/model/DocumentVersion.java | 50 +++ .../repository/DocumentVersionRepository.java | 17 + .../service/DocumentVersionService.java | 209 +++++++++++ .../migration/V9__add_document_versions.sql | 11 + .../service/DocumentVersionServiceTest.java | 329 ++++++++++++++++++ 6 files changed, 630 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentVersionSummary.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/DocumentVersion.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentVersionRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java create mode 100644 backend/src/main/resources/db/migration/V9__add_document_versions.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentVersionSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentVersionSummary.java new file mode 100644 index 00000000..8ad065d2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentVersionSummary.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record DocumentVersionSummary( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime savedAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String editorName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List changedFields +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentVersion.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentVersion.java new file mode 100644 index 00000000..8d9e4d22 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentVersion.java @@ -0,0 +1,50 @@ +package org.raddatz.familienarchiv.model; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "document_versions") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DocumentVersion { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(name = "document_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID documentId; + + @Column(name = "saved_at", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime savedAt; + + @Column(name = "editor_id") + private UUID editorId; + + @Column(name = "editor_name", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String editorName; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String snapshot; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "changed_fields", columnDefinition = "jsonb", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String changedFields; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentVersionRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentVersionRepository.java new file mode 100644 index 00000000..b1f58ee2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentVersionRepository.java @@ -0,0 +1,17 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.DocumentVersion; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DocumentVersionRepository extends JpaRepository { + + List findByDocumentIdOrderBySavedAtAsc(UUID documentId); + + Optional findByIdAndDocumentId(UUID id, UUID documentId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java new file mode 100644 index 00000000..8d595478 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java @@ -0,0 +1,209 @@ +package org.raddatz.familienarchiv.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.DocumentVersionSummary; +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.DocumentVersion; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.repository.DocumentVersionRepository; +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.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DocumentVersionService { + + private final DocumentVersionRepository versionRepository; + private final UserService userService; + private final ObjectMapper objectMapper; + + @Transactional + public void recordVersion(Document doc) { + AppUser editor = resolveCurrentUser(); + String editorName = buildEditorName(editor); + UUID editorId = editor != null ? editor.getId() : null; + + String snapshot = serializeSnapshot(doc); + List previous = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId()); + String changedFields = computeChangedFields(doc, previous); + + versionRepository.save(DocumentVersion.builder() + .documentId(doc.getId()) + .savedAt(LocalDateTime.now()) + .editorId(editorId) + .editorName(editorName) + .snapshot(snapshot) + .changedFields(changedFields) + .build()); + } + + public List getSummaries(UUID documentId) { + return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream() + .map(v -> new DocumentVersionSummary( + v.getId(), + v.getSavedAt(), + v.getEditorName(), + parseChangedFields(v.getChangedFields()))) + .toList(); + } + + public DocumentVersion getVersion(UUID documentId, UUID versionId) { + return versionRepository.findByIdAndDocumentId(versionId, documentId) + .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, + "Version not found: " + versionId)); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private AppUser resolveCurrentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + try { + return userService.findByUsername(auth.getName()); + } catch (Exception e) { + log.warn("Could not resolve editor for version snapshot: {}", e.getMessage()); + return null; + } + } + + private String buildEditorName(AppUser user) { + if (user == null) return "Unknown"; + String first = user.getFirstName(); + String last = user.getLastName(); + if (first != null && !first.isBlank() && last != null && !last.isBlank()) { + return first + " " + last; + } + return user.getUsername(); + } + + private String serializeSnapshot(Document doc) { + try { + return objectMapper.writeValueAsString(doc); + } catch (Exception e) { + log.error("Failed to serialize document snapshot for {}", doc.getId(), e); + return "{}"; + } + } + + private String computeChangedFields(Document current, List previousVersions) { + if (previousVersions.isEmpty()) { + return "[]"; + } + DocumentVersion last = previousVersions.get(previousVersions.size() - 1); + try { + Map previousMap = objectMapper.readValue( + last.getSnapshot(), new TypeReference<>() {}); + List changed = new ArrayList<>(); + + checkScalar(changed, "title", current.getTitle(), previousMap); + checkScalar(changed, "documentDate", + current.getDocumentDate() != null ? current.getDocumentDate().toString() : null, + previousMap); + checkScalar(changed, "location", current.getLocation(), previousMap); + checkScalar(changed, "documentLocation", current.getDocumentLocation(), previousMap); + checkScalar(changed, "transcription", current.getTranscription(), previousMap); + checkScalar(changed, "summary", current.getSummary(), previousMap); + checkSender(changed, current, previousMap); + checkReceivers(changed, current, previousMap); + checkTags(changed, current, previousMap); + + return objectMapper.writeValueAsString(changed); + } catch (Exception e) { + log.warn("Could not compute changedFields for document {}", current.getId(), e); + return "[]"; + } + } + + private void checkScalar(List changed, String field, String currentValue, + Map previousMap) { + Object prev = previousMap.get(field); + String prevStr = prev != null ? prev.toString() : null; + if (!Objects.equals(currentValue, prevStr)) { + changed.add(field); + } + } + + @SuppressWarnings("unchecked") + private void checkSender(List changed, Document current, Map previousMap) { + String currentId = current.getSender() != null + ? current.getSender().getId().toString() : null; + Object prevSender = previousMap.get("sender"); + String prevId = null; + if (prevSender instanceof Map senderMap) { + Object id = senderMap.get("id"); + prevId = id != null ? id.toString() : null; + } + if (!Objects.equals(currentId, prevId)) { + changed.add("sender"); + } + } + + @SuppressWarnings("unchecked") + private void checkReceivers(List changed, Document current, Map previousMap) { + Set currentIds = current.getReceivers() != null + ? current.getReceivers().stream().map(p -> p.getId().toString()).collect(Collectors.toSet()) + : Set.of(); + Object prevReceivers = previousMap.get("receivers"); + Set prevIds = Set.of(); + if (prevReceivers instanceof List list) { + prevIds = list.stream() + .filter(r -> r instanceof Map) + .map(r -> ((Map) r).get("id")) + .filter(Objects::nonNull) + .map(Object::toString) + .collect(Collectors.toSet()); + } + if (!currentIds.equals(prevIds)) { + changed.add("receivers"); + } + } + + @SuppressWarnings("unchecked") + private void checkTags(List changed, Document current, Map previousMap) { + Set currentNames = current.getTags() != null + ? current.getTags().stream().map(Tag::getName).collect(Collectors.toSet()) + : Set.of(); + Object prevTags = previousMap.get("tags"); + Set prevNames = Set.of(); + if (prevTags instanceof List list) { + prevNames = list.stream() + .filter(t -> t instanceof Map) + .map(t -> ((Map) t).get("name")) + .filter(Objects::nonNull) + .map(Object::toString) + .collect(Collectors.toSet()); + } + if (!currentNames.equals(prevNames)) { + changed.add("tags"); + } + } + + private List parseChangedFields(String json) { + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/backend/src/main/resources/db/migration/V9__add_document_versions.sql b/backend/src/main/resources/db/migration/V9__add_document_versions.sql new file mode 100644 index 00000000..5d1353b3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_document_versions.sql @@ -0,0 +1,11 @@ +CREATE TABLE document_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + saved_at TIMESTAMP NOT NULL DEFAULT now(), + editor_id UUID REFERENCES users(id) ON DELETE SET NULL, + editor_name VARCHAR(200) NOT NULL, + snapshot JSONB NOT NULL, + changed_fields JSONB NOT NULL DEFAULT '[]' +); + +CREATE INDEX ON document_versions (document_id, saved_at DESC); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java new file mode 100644 index 00000000..992cf6d4 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java @@ -0,0 +1,329 @@ +package org.raddatz.familienarchiv.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.DocumentVersionSummary; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentVersion; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.repository.DocumentVersionRepository; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; +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.*; + +@ExtendWith(MockitoExtension.class) +class DocumentVersionServiceTest { + + @Mock DocumentVersionRepository versionRepository; + @Mock UserService userService; + + private DocumentVersionService versionService; + + @BeforeEach + void setUp() { + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + versionService = new DocumentVersionService(versionRepository, userService, mapper); + } + + @BeforeEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + // ─── recordVersion — editor name ───────────────────────────────────────── + + @Test + void recordVersion_usesFirstAndLastName_whenBothPresent() { + authenticateAs("emma"); + when(userService.findByUsername("emma")).thenReturn( + AppUser.builder().id(UUID.randomUUID()).username("emma") + .firstName("Emma").lastName("Müller").build()); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.recordVersion(minimalDocument()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getEditorName()).isEqualTo("Emma Müller"); + } + + @Test + void recordVersion_usesUsername_whenNamesAreBlank() { + authenticateAs("otto99"); + when(userService.findByUsername("otto99")).thenReturn( + AppUser.builder().id(UUID.randomUUID()).username("otto99") + .firstName(null).lastName(null).build()); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.recordVersion(minimalDocument()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getEditorName()).isEqualTo("otto99"); + } + + // ─── recordVersion — snapshot ───────────────────────────────────────────── + + @Test + void recordVersion_savesSnapshotContainingTitle() { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document doc = Document.builder() + .id(UUID.randomUUID()) + .title("Wichtiger Brief") + .originalFilename("brief.pdf") + .build(); + + versionService.recordVersion(doc); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getSnapshot()).contains("Wichtiger Brief"); + assertThat(captor.getValue().getDocumentId()).isEqualTo(doc.getId()); + } + + // ─── recordVersion — changedFields ──────────────────────────────────────── + + @Test + void recordVersion_changedFieldsIsEmpty_forFirstVersion() { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.recordVersion(minimalDocument()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).isEqualTo("[]"); + } + + @Test + void recordVersion_includesTitleInChangedFields_whenTitleChanged() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + Document oldDoc = Document.builder().id(UUID.randomUUID()).title("Alt").build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()) + .documentId(oldDoc.getId()) + .snapshot(oldSnapshot) + .changedFields("[]") + .savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1") + .build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(oldDoc.getId())) + .thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document updated = Document.builder().id(oldDoc.getId()).title("Neu").build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("title"); + } + + @Test + void recordVersion_doesNotIncludeUnchangedFields_inChangedFields() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + UUID docId = UUID.randomUUID(); + Document oldDoc = Document.builder().id(docId).title("Same").location("Berlin").build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot) + .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1").build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document updated = Document.builder().id(docId).title("Same").location("Hamburg").build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("location"); + assertThat(captor.getValue().getChangedFields()).doesNotContain("title"); + } + + @Test + void recordVersion_tracksSenderChange() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + UUID docId = UUID.randomUUID(); + Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build(); + Document oldDoc = Document.builder().id(docId).title("T").sender(oldSender).build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot) + .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1").build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Person newSender = Person.builder().id(UUID.randomUUID()).firstName("C").lastName("D").build(); + Document updated = Document.builder().id(docId).title("T").sender(newSender).build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("sender"); + } + + @Test + void recordVersion_tracksReceiverChange() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + UUID docId = UUID.randomUUID(); + Person receiver1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build(); + Document oldDoc = Document.builder().id(docId).title("T").receivers(Set.of(receiver1)).build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot) + .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1").build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document updated = Document.builder().id(docId).title("T").receivers(Set.of()).build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("receivers"); + } + + @Test + void recordVersion_tracksTagChange() throws Exception { + authenticateAs("user1"); + when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); + + ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + UUID docId = UUID.randomUUID(); + Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build(); + Document oldDoc = Document.builder().id(docId).title("T").tags(Set.of(tag)).build(); + String oldSnapshot = mapper.writeValueAsString(oldDoc); + + DocumentVersion previous = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot) + .changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5)) + .editorName("user1").build(); + + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous)); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Document updated = Document.builder().id(docId).title("T").tags(Set.of()).build(); + versionService.recordVersion(updated); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).contains("tags"); + } + + // ─── getSummaries ───────────────────────────────────────────────────────── + + @Test + void getSummaries_returnsListWithParsedChangedFields() { + UUID docId = UUID.randomUUID(); + DocumentVersion v = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(docId) + .savedAt(LocalDateTime.now()).editorName("Emma Müller") + .snapshot("{}").changedFields("[\"title\",\"location\"]") + .build(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(v)); + + List summaries = versionService.getSummaries(docId); + + assertThat(summaries).hasSize(1); + assertThat(summaries.get(0).editorName()).isEqualTo("Emma Müller"); + assertThat(summaries.get(0).changedFields()).containsExactlyInAnyOrder("title", "location"); + assertThat(summaries.get(0).id()).isEqualTo(v.getId()); + } + + // ─── getVersion ─────────────────────────────────────────────────────────── + + @Test + void getVersion_returnsVersion_whenFound() { + UUID docId = UUID.randomUUID(); + UUID versionId = UUID.randomUUID(); + DocumentVersion v = DocumentVersion.builder() + .id(versionId).documentId(docId).snapshot("{}") + .changedFields("[]").editorName("x").savedAt(LocalDateTime.now()).build(); + when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.of(v)); + + assertThat(versionService.getVersion(docId, versionId)).isEqualTo(v); + } + + @Test + void getVersion_throwsNotFound_whenVersionBelongsToOtherDocument() { + UUID docId = UUID.randomUUID(); + UUID versionId = UUID.randomUUID(); + when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> versionService.getVersion(docId, versionId)) + .isInstanceOf(DomainException.class); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + private void authenticateAs(String username) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(username, null, List.of())); + } + + private AppUser stubUser(String username) { + return AppUser.builder().id(UUID.randomUUID()).username(username) + .firstName(null).lastName(null).build(); + } + + private Document minimalDocument() { + return Document.builder() + .id(UUID.randomUUID()) + .title("Test") + .originalFilename("test.pdf") + .documentDate(LocalDate.of(1940, 5, 1)) + .build(); + } +} -- 2.49.1 From 28256dbd08b2b4be83d877faae6c126f4c8234e6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 11:30:05 +0100 Subject: [PATCH 02/13] feat: wire document versioning into DocumentService and DocumentController DocumentService now calls documentVersionService.recordVersion() after createDocument and updateDocument. DocumentController exposes two new read-only endpoints: GET /{id}/versions and GET /{id}/versions/{versionId}. Refs #38 Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 18 +++++++ .../service/DocumentService.java | 9 +++- .../controller/DocumentControllerTest.java | 51 +++++++++++++++++++ .../service/DocumentServiceTest.java | 35 +++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index a4b30d81..f23f465b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -5,13 +5,18 @@ import java.time.LocalDate; import java.util.List; import java.util.UUID; + + import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; +import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.DocumentService; +import org.raddatz.familienarchiv.service.DocumentVersionService; import org.raddatz.familienarchiv.service.FileService; import org.springframework.data.domain.Sort; import org.springframework.http.HttpHeaders; @@ -39,6 +44,7 @@ import lombok.extern.slf4j.Slf4j; public class DocumentController { private final DocumentService documentService; + private final DocumentVersionService documentVersionService; private final FileService fileService; // --- DOWNLOAD --- @@ -108,6 +114,18 @@ public class DocumentController { return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags)); } + // --- VERSIONS --- + + @GetMapping("/{id}/versions") + public List getVersions(@PathVariable UUID id) { + return documentVersionService.getSummaries(id); + } + + @GetMapping("/{id}/versions/{versionId}") + public DocumentVersion getVersion(@PathVariable UUID id, @PathVariable UUID versionId) { + return documentVersionService.getVersion(id, versionId); + } + @GetMapping("/conversation") public List getConversation( @RequestParam UUID senderId, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 2c8f5ce1..303fda53 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -37,6 +37,7 @@ public class DocumentService { private final PersonService personService; private final FileService fileService; private final TagService tagService; + private final DocumentVersionService documentVersionService; /** * Lädt eine Datei hoch. @@ -125,7 +126,9 @@ public class DocumentService { doc.setStatus(DocumentStatus.UPLOADED); } - return documentRepository.save(doc); + Document finalDoc = documentRepository.save(doc); + documentVersionService.recordVersion(finalDoc); + return finalDoc; } @Transactional @@ -178,7 +181,9 @@ public class DocumentService { doc.setStatus(DocumentStatus.UPLOADED); } - return documentRepository.save(doc); + Document saved = documentRepository.save(doc); + documentVersionService.recordVersion(saved); + return saved; } public Document updateDocumentTags(UUID docId, List tagNames) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index a560d4d3..545e98f3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -1,10 +1,13 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.DocumentService; +import org.raddatz.familienarchiv.service.DocumentVersionService; import org.raddatz.familienarchiv.service.FileService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -15,13 +18,16 @@ 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.Collections; +import java.util.List; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(DocumentController.class) @@ -31,6 +37,7 @@ class DocumentControllerTest { @Autowired MockMvc mockMvc; @MockitoBean DocumentService documentService; + @MockitoBean DocumentVersionService documentVersionService; @MockitoBean FileService fileService; @MockitoBean CustomUserDetailsService customUserDetailsService; @@ -113,4 +120,48 @@ class DocumentControllerTest { .with(req -> { req.setMethod("PUT"); return req; })) .andExpect(status().isOk()); } + + // ─── GET /api/documents/{id}/versions ──────────────────────────────────── + + @Test + void getVersions_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getVersions_returns200_whenAuthenticated() throws Exception { + UUID docId = UUID.randomUUID(); + DocumentVersionSummary summary = new DocumentVersionSummary( + UUID.randomUUID(), LocalDateTime.now(), "Emma Müller", List.of("title")); + when(documentVersionService.getSummaries(docId)).thenReturn(List.of(summary)); + + mockMvc.perform(get("/api/documents/" + docId + "/versions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].editorName").value("Emma Müller")); + } + + // ─── GET /api/documents/{id}/versions/{versionId} ──────────────────────── + + @Test + void getVersion_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions/" + UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getVersion_returns200_whenAuthenticated() throws Exception { + UUID docId = UUID.randomUUID(); + UUID versionId = UUID.randomUUID(); + DocumentVersion version = DocumentVersion.builder() + .id(versionId).documentId(docId).savedAt(LocalDateTime.now()) + .editorName("Otto").snapshot("{\"title\":\"Brief\"}").changedFields("[]").build(); + when(documentVersionService.getVersion(docId, versionId)).thenReturn(version); + + mockMvc.perform(get("/api/documents/" + docId + "/versions/" + versionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.editorName").value("Otto")); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index d3ff6cdd..e531bc44 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -8,6 +8,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; @@ -29,6 +30,7 @@ class DocumentServiceTest { @Mock PersonService personService; @Mock FileService fileService; @Mock TagService tagService; + @Mock DocumentVersionService documentVersionService; @InjectMocks DocumentService documentService; // ─── getDocumentById ────────────────────────────────────────────────────── @@ -132,4 +134,37 @@ class DocumentServiceTest { assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc); } + + // ─── versioning ─────────────────────────────────────────────────────────── + + @Test + void createDocument_recordsVersionAfterSave() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("Neuer Brief"); + + Document saved = Document.builder() + .id(UUID.randomUUID()).title("Neuer Brief") + .originalFilename("Neuer Brief").status(DocumentStatus.PLACEHOLDER) + .build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + + documentService.createDocument(dto, null); + + verify(documentVersionService, atLeastOnce()).recordVersion(any(Document.class)); + } + + @Test + void updateDocument_recordsVersionAfterSave() throws Exception { + UUID id = UUID.randomUUID(); + Document existing = Document.builder() + .id(id).title("Alt").originalFilename("alt.pdf") + .status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(existing)); + when(documentRepository.save(any())).thenReturn(existing); + + documentService.updateDocument(id, new DocumentUpdateDTO(), null); + + verify(documentVersionService).recordVersion(any(Document.class)); + } } -- 2.49.1 From 7af49daf9c41fca194988fd1562247bdc3df8efa Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 11:41:16 +0100 Subject: [PATCH 03/13] fix: use tools.jackson (Jackson 3) instead of com.fasterxml.jackson in DocumentVersionService Spring Boot 4 auto-configures a tools.jackson.databind.ObjectMapper bean. The service was importing the Jackson 2 package, causing a no-qualifying-bean error at startup. Refs #38 Co-Authored-By: Claude Sonnet 4.6 --- .../service/DocumentVersionService.java | 4 ++-- .../service/DocumentVersionServiceTest.java | 16 +++++++--------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java index 8d595478..70720551 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java @@ -1,7 +1,7 @@ package org.raddatz.familienarchiv.service; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java index 992cf6d4..cb75cfd0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java @@ -1,7 +1,6 @@ package org.raddatz.familienarchiv.service; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import tools.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,8 +40,7 @@ class DocumentVersionServiceTest { @BeforeEach void setUp() { - ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); - versionService = new DocumentVersionService(versionRepository, userService, mapper); + versionService = new DocumentVersionService(versionRepository, userService, new ObjectMapper()); } @BeforeEach @@ -128,7 +126,7 @@ class DocumentVersionServiceTest { authenticateAs("user1"); when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); - ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + ObjectMapper mapper = new ObjectMapper(); Document oldDoc = Document.builder().id(UUID.randomUUID()).title("Alt").build(); String oldSnapshot = mapper.writeValueAsString(oldDoc); @@ -158,7 +156,7 @@ class DocumentVersionServiceTest { authenticateAs("user1"); when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); - ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + ObjectMapper mapper = new ObjectMapper(); UUID docId = UUID.randomUUID(); Document oldDoc = Document.builder().id(docId).title("Same").location("Berlin").build(); String oldSnapshot = mapper.writeValueAsString(oldDoc); @@ -185,7 +183,7 @@ class DocumentVersionServiceTest { authenticateAs("user1"); when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); - ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + ObjectMapper mapper = new ObjectMapper(); UUID docId = UUID.randomUUID(); Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build(); Document oldDoc = Document.builder().id(docId).title("T").sender(oldSender).build(); @@ -213,7 +211,7 @@ class DocumentVersionServiceTest { authenticateAs("user1"); when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); - ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + ObjectMapper mapper = new ObjectMapper(); UUID docId = UUID.randomUUID(); Person receiver1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build(); Document oldDoc = Document.builder().id(docId).title("T").receivers(Set.of(receiver1)).build(); @@ -240,7 +238,7 @@ class DocumentVersionServiceTest { authenticateAs("user1"); when(userService.findByUsername("user1")).thenReturn(stubUser("user1")); - ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + ObjectMapper mapper = new ObjectMapper(); UUID docId = UUID.randomUUID(); Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build(); Document oldDoc = Document.builder().id(docId).title("T").tags(Set.of(tag)).build(); -- 2.49.1 From d4b1a709d7b22735a8757c516f9576b6a5f36d5f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 11:57:33 +0100 Subject: [PATCH 04/13] feat(frontend): add document history panel with diff and compare mode Adds a collapsible history section to the document detail view, showing all saved versions with changed-field labels, word-level diff between adjacent versions, and a compare mode for any two arbitrary versions. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 20 +- frontend/messages/en.json | 20 +- frontend/messages/es.json | 20 +- frontend/package-lock.json | 18 + frontend/package.json | 2 + frontend/src/lib/generated/api.ts | 98 ++++ .../src/routes/documents/[id]/+page.svelte | 438 ++++++++++++++++++ frontend/yarn.lock | 10 + 8 files changed, 623 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e614ac93..95bbbc3e 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -215,5 +215,23 @@ "reset_password_submit": "Passwort speichern", "reset_password_mismatch": "Die Passwörter stimmen nicht überein.", "reset_password_success": "Ihr Passwort wurde erfolgreich geändert. Sie können sich jetzt anmelden.", - "login_forgot_password": "Passwort vergessen?" + "login_forgot_password": "Passwort vergessen?", + "history_section_title": "Verlauf", + "history_loading": "Lade Verlauf…", + "history_empty": "Noch keine Versionen vorhanden.", + "history_version_label": "Version", + "history_compare_mode": "Vergleichen", + "history_compare_select_a": "Version A", + "history_compare_select_b": "Version B", + "history_compare_apply": "Vergleichen", + "history_diff_no_changes": "Keine Änderungen zwischen diesen Versionen.", + "history_field_title": "Titel", + "history_field_document_date": "Datum", + "history_field_location": "Ort", + "history_field_document_location": "Archivstandort", + "history_field_transcription": "Transkription", + "history_field_summary": "Zusammenfassung", + "history_field_sender": "Absender", + "history_field_receivers": "Empfänger", + "history_field_tags": "Schlagworte" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 79d96300..ce0441cb 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -215,5 +215,23 @@ "reset_password_submit": "Save password", "reset_password_mismatch": "The passwords do not match.", "reset_password_success": "Your password has been changed successfully. You can now log in.", - "login_forgot_password": "Forgot password?" + "login_forgot_password": "Forgot password?", + "history_section_title": "History", + "history_loading": "Loading history…", + "history_empty": "No versions yet.", + "history_version_label": "Version", + "history_compare_mode": "Compare", + "history_compare_select_a": "Version A", + "history_compare_select_b": "Version B", + "history_compare_apply": "Compare", + "history_diff_no_changes": "No changes between these versions.", + "history_field_title": "Title", + "history_field_document_date": "Date", + "history_field_location": "Location", + "history_field_document_location": "Archive location", + "history_field_transcription": "Transcription", + "history_field_summary": "Summary", + "history_field_sender": "Sender", + "history_field_receivers": "Receivers", + "history_field_tags": "Tags" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f12d6e40..29c1f750 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -215,5 +215,23 @@ "reset_password_submit": "Guardar contraseña", "reset_password_mismatch": "Las contraseñas no coinciden.", "reset_password_success": "Su contraseña ha sido cambiada con éxito. Ahora puede iniciar sesión.", - "login_forgot_password": "¿Olvidó su contraseña?" + "login_forgot_password": "¿Olvidó su contraseña?", + "history_section_title": "Historial", + "history_loading": "Cargando historial…", + "history_empty": "Aún no hay versiones.", + "history_version_label": "Versión", + "history_compare_mode": "Comparar", + "history_compare_select_a": "Versión A", + "history_compare_select_b": "Versión B", + "history_compare_apply": "Comparar", + "history_diff_no_changes": "No hay cambios entre estas versiones.", + "history_field_title": "Título", + "history_field_document_date": "Fecha", + "history_field_location": "Lugar", + "history_field_document_location": "Ubicación en archivo", + "history_field_transcription": "Transcripción", + "history_field_summary": "Resumen", + "history_field_sender": "Remitente", + "history_field_receivers": "Destinatarios", + "history_field_tags": "Etiquetas" } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index de2738e0..6795fc10 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.1", "dependencies": { + "diff": "^8.0.3", "openapi-fetch": "^0.13.5" }, "devDependencies": { @@ -21,6 +22,7 @@ "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.17", + "@types/diff": "^7.0.2", "@types/node": "^24", "@vitest/browser-playwright": "^4.0.10", "eslint": "^9.39.1", @@ -1895,6 +1897,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2780,6 +2789,15 @@ "dev": true, "license": "MIT" }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8485aecf..7e8ded46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts" }, "dependencies": { + "diff": "^8.0.3", "openapi-fetch": "^0.13.5" }, "devDependencies": { @@ -33,6 +34,7 @@ "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.17", + "@types/diff": "^7.0.2", "@types/node": "^24", "@vitest/browser-playwright": "^4.0.10", "eslint": "^9.39.1", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 46539816..f86f61dc 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -308,6 +308,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{id}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getVersions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{id}/versions/{versionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getVersion"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{id}/file": { parameters: { query?: never; @@ -516,6 +548,27 @@ export interface components { /** Format: date-time */ startedAt?: string; }; + DocumentVersionSummary: { + /** Format: uuid */ + id: string; + /** Format: date-time */ + savedAt: string; + editorName: string; + changedFields: string[]; + }; + DocumentVersion: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + documentId: string; + /** Format: date-time */ + savedAt: string; + /** Format: uuid */ + editorId?: string; + editorName: string; + snapshot: string; + changedFields: string; + }; }; responses: never; parameters: never; @@ -1189,6 +1242,51 @@ export interface operations { }; }; }; + getVersions: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DocumentVersionSummary"][]; + }; + }; + }; + }; + getVersion: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + versionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DocumentVersion"]; + }; + }; + }; + }; getDocumentFile: { parameters: { query?: never; diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 545fc804..1f65532a 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,6 +1,7 @@
@@ -346,6 +578,212 @@ async function loadFile(id: string) {
{/if} + +
+
+

+ {m.history_section_title()} +

+ +
+ + {#if historyOpen} +
+ {#if historyLoading} +

{m.history_loading()}

+ {:else if versions.length === 0} +

{m.history_empty()}

+ {:else} + +
+ +
+ + {#if compareMode} + +
+
+ + +
+
+ + +
+ +
+ {:else} + +
    + {#each versions as v, i (v.id)} +
  • + +
  • + {/each} +
+ {/if} + + + {#if diffLoading} +

{m.history_loading()}

+ {:else if noDiff} +
+ {m.history_diff_no_changes()} +
+ {:else if diffEntries.length > 0} +
+ {#each diffEntries as entry (entry.field)} +
+ {entry.label} + {#if entry.kind === 'text'} +

+ {#each entry.parts as part, partIdx (partIdx)} + {#if part.added} + {part.value} + {:else if part.removed} + {part.value} + {:else} + {part.value} + {/if} + {/each} +

+ {:else if entry.kind === 'scalar'} +
+ {entry.oldVal || '—'} + + {entry.newVal || '—'} +
+ {:else if entry.kind === 'relation'} +
+ {#each entry.removed as item (item)} + {item} + {/each} + {#each entry.added as item (item)} + {item} + {/each} +
+ {/if} +
+ {/each} +
+ {/if} + {/if} +
+ {/if} +
+

ID: {doc.id}

diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 2081246d..386ec56f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -452,6 +452,11 @@ resolved "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz" integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== +"@types/diff@^7.0.2": + version "7.0.2" + resolved "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz" + integrity sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q== + "@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6", "@types/estree@1.0.8": version "1.0.8" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" @@ -888,6 +893,11 @@ devalue@^5.6.4: resolved "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz" integrity sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA== +diff@^8.0.3: + version "8.0.3" + resolved "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz" + integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ== + enhanced-resolve@^5.19.0: version "5.20.0" resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz" -- 2.49.1 From 4a0d3b3beabe2d4a0c828725952b2abefafaef2f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 11:59:43 +0100 Subject: [PATCH 05/13] test(e2e): add history panel playwright spec Three scenarios: versions list appears after edits, diff shows changed field, compare mode displays diff between two selected versions. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/history.spec.ts | 89 +++++++++++++++++++ .../src/routes/documents/[id]/+page.svelte | 7 +- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/history.spec.ts diff --git a/frontend/e2e/history.spec.ts b/frontend/e2e/history.spec.ts new file mode 100644 index 00000000..8e70b702 --- /dev/null +++ b/frontend/e2e/history.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +/** + * Document edit history E2E tests. + * Relies on the 'Document creation' and 'Document editing' tests in documents.spec.ts + * having run first (they create and edit "E2E Testbrief (überarbeitet)"). + * Assumes auth setup has run. + */ +test.describe('Document history panel', () => { + test('history section appears after creating and editing a document', async ({ page }) => { + // Find the document edited in the documents.spec.ts editing test + await page.goto('/?q=E2E+Testbrief'); + await page.waitForSelector('[data-hydrated]'); + const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first(); + const href = await docLink.getAttribute('href'); + await page.goto(href!); + + // History section should be present (collapsed by default) + const historyToggle = page.getByRole('button', { name: /Verlauf/i }); + await expect(historyToggle).toBeVisible(); + + // Expand the history section + await historyToggle.click(); + + // Should show at least two version entries (created + edited) + const versionItems = page.locator('[data-testid="history-version"]'); + await expect(versionItems).toHaveCount(2, { timeout: 5000 }); + + await page.screenshot({ path: 'test-results/e2e/history-versions-list.png' }); + }); + + test('diff view highlights changed field after title edit', async ({ page }) => { + await page.goto('/?q=E2E+Testbrief'); + await page.waitForSelector('[data-hydrated]'); + const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first(); + const href = await docLink.getAttribute('href'); + await page.goto(href!); + + // Expand history + const historyToggle = page.getByRole('button', { name: /Verlauf/i }); + await historyToggle.click(); + + // Click the second version (the edit) to see its diff + const versionItems = page.locator('[data-testid="history-version"]'); + await versionItems.nth(1).click(); + + // The diff panel should show the "Titel" field as changed + const diffPanel = page.locator('[data-testid="history-diff"]'); + await expect(diffPanel).toBeVisible(); + await expect(diffPanel.getByText(/Titel/i)).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/history-diff-title.png' }); + }); + + test('compare mode lets user compare any two versions', async ({ page }) => { + await page.goto('/?q=E2E+Testbrief'); + await page.waitForSelector('[data-hydrated]'); + const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first(); + const href = await docLink.getAttribute('href'); + await page.goto(href!); + + // Expand history + const historyToggle = page.getByRole('button', { name: /Verlauf/i }); + await historyToggle.click(); + + // Switch to compare mode + const compareBtn = page.getByRole('button', { name: /Vergleichen/i }); + await expect(compareBtn).toBeVisible(); + await compareBtn.click(); + + // Two version selects should appear + const selectA = page.getByLabel(/Version A/i); + const selectB = page.getByLabel(/Version B/i); + await expect(selectA).toBeVisible(); + await expect(selectB).toBeVisible(); + + // Apply the comparison + await page + .getByRole('button', { name: /Vergleichen/i }) + .last() + .click(); + + // Diff panel should be visible + const diffPanel = page.locator('[data-testid="history-diff"]'); + await expect(diffPanel).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/history-compare-mode.png' }); + }); +}); diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 1f65532a..facb83a2 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -681,6 +681,7 @@ function versionLabel(v: VersionSummary, index: number): string {
  • +
  • @@ -495,5 +518,22 @@ function cancelEditGroup() { + {:else if activeTab === 'system'} +
    +

    {m.admin_system_backfill_heading()}

    +

    {m.admin_system_backfill_description()}

    + + {#if backfillResult !== null} +

    + {m.admin_system_backfill_success({ count: backfillResult })} +

    + {/if} +
    {/if} -- 2.49.1 From 0020d1e773d124f4e4e5c7051d76324c8a721296 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 12:46:56 +0100 Subject: [PATCH 09/13] fix(frontend): improve PDF zoom and diff readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDF viewer: append #zoom=page-width to iframe src so A4 letters fill the panel width instead of leaving large grey gutters - Diff view: trim unchanged context to 4 words either side of each change, replacing long runs with '…' so edits are easy to spot Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/documents/[id]/+page.svelte | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 7b2d6f0d..4cd12d7e 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -112,6 +112,44 @@ function personLabel(p: { firstName: string; lastName: string }): string { return `${p.firstName} ${p.lastName}`.trim(); } +const DIFF_CONTEXT_WORDS = 4; + +type DiffPart = { value: string; added?: boolean; removed?: boolean }; + +function trimContextParts(parts: DiffPart[]): DiffPart[] { + return parts.flatMap((part, i) => { + if (part.added || part.removed) return [part]; + const tokens = part.value.split(/(\s+)/).filter(Boolean); + const wordCount = tokens.filter((t) => /\S/.test(t)).length; + if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part]; + + function keepFirst(n: number): string { + let count = 0; + const out: string[] = []; + for (const t of tokens) { + out.push(t); + if (/\S/.test(t) && ++count >= n) break; + } + return out.join(''); + } + function keepLast(n: number): string { + let count = 0; + const out: string[] = []; + for (const t of [...tokens].reverse()) { + out.unshift(t); + if (/\S/.test(t) && ++count >= n) break; + } + return out.join(''); + } + + const isFirst = i === 0; + const isLast = i === parts.length - 1; + if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }]; + if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }]; + return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }]; + }); +} + function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] { const entries: DiffEntry[] = []; @@ -119,7 +157,7 @@ function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] { const a = older?.[field] ?? ''; const b = newer[field] ?? ''; if (a === b) continue; - const parts = diffWords(a, b); + const parts = trimContextParts(diffWords(a, b)); entries.push({ kind: 'text', field, label: fieldLabels[field](), parts }); } @@ -844,14 +882,14 @@ function versionLabel(v: VersionSummary, index: number): string { {:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')} {:else if fileUrl}
    {m.doc_image_alt()} -- 2.49.1 From 8c86beb9f9aacee52efe6bb83283095479bbdf0d Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 12:53:04 +0100 Subject: [PATCH 10/13] feat(frontend): add expandable text component for long fields Adds ExpandableText.svelte which clamps text to 10 lines and shows a toggle button only when the content actually overflows. Applied to the summary and transcription fields on the document detail page. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 4 ++- frontend/messages/en.json | 4 ++- frontend/messages/es.json | 4 ++- .../src/lib/components/ExpandableText.svelte | 33 +++++++++++++++++++ .../src/routes/documents/[id]/+page.svelte | 13 ++------ 5 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 frontend/src/lib/components/ExpandableText.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 228b7815..017d4a29 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -238,5 +238,7 @@ "admin_system_backfill_heading": "Verlaufsdaten auffüllen", "admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.", "admin_system_backfill_btn": "Jetzt auffüllen", - "admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt." + "admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.", + "comp_expandable_show_more": "Mehr anzeigen", + "comp_expandable_show_less": "Weniger anzeigen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cca54480..23f677d1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -238,5 +238,7 @@ "admin_system_backfill_heading": "Backfill history data", "admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.", "admin_system_backfill_btn": "Backfill now", - "admin_system_backfill_success": "{count} documents were backfilled." + "admin_system_backfill_success": "{count} documents were backfilled.", + "comp_expandable_show_more": "Show more", + "comp_expandable_show_less": "Show less" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index e72775be..595a0dba 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -238,5 +238,7 @@ "admin_system_backfill_heading": "Completar datos de historial", "admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.", "admin_system_backfill_btn": "Completar ahora", - "admin_system_backfill_success": "{count} documentos fueron completados." + "admin_system_backfill_success": "{count} documentos fueron completados.", + "comp_expandable_show_more": "Mostrar más", + "comp_expandable_show_less": "Mostrar menos" } diff --git a/frontend/src/lib/components/ExpandableText.svelte b/frontend/src/lib/components/ExpandableText.svelte new file mode 100644 index 00000000..a87b13b5 --- /dev/null +++ b/frontend/src/lib/components/ExpandableText.svelte @@ -0,0 +1,33 @@ + + +
    +
    + {text} +
    + {#if isClamped || expanded} + + {/if} +
    diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 4cd12d7e..62abbe88 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -2,6 +2,7 @@ import { m } from '$lib/paraglide/messages.js'; import { formatDate } from '$lib/utils/date'; import { diffWords } from 'diff'; +import ExpandableText from '$lib/components/ExpandableText.svelte'; let { data } = $props(); @@ -592,11 +593,7 @@ function versionLabel(v: VersionSummary, index: number): string { {m.doc_label_summary()} -
    - {doc.summary} -
    +
    {/if} @@ -605,11 +602,7 @@ function versionLabel(v: VersionSummary, index: number): string { {m.form_label_transcription()} -
    - {doc.transcription} -
    + {/if} -- 2.49.1 From d84b997965461f16d74fb4e9e6f94e38acf87fd0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 13:05:31 +0100 Subject: [PATCH 11/13] fix(frontend): show version numbers oldest-first (1 = oldest) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/documents/[id]/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 62abbe88..75a03373 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -307,7 +307,7 @@ function formatDateTime(iso: string): string { } function versionLabel(v: VersionSummary, index: number): string { - return `Version ${versions.length - index} — ${v.editorName} — ${formatDateTime(v.savedAt)}`; + return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`; } @@ -720,7 +720,7 @@ function versionLabel(v: VersionSummary, index: number): string { >
    - Version {versions.length - i} + Version {i + 1} {formatDateTime(v.savedAt)} -- 2.49.1 From 62f62a89a1e7908d4e59658e1dac54466397e3bb Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 13:37:39 +0100 Subject: [PATCH 12/13] fix(e2e): wait for hydration on document detail page in history tests All three history tests navigated to the doc page but didn't wait for SvelteKit hydration, so the toggle onclick wasn't registered yet. Also wait for versions to load (API call) before asserting on version items or the compare button. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/history.spec.ts | 87 +++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/frontend/e2e/history.spec.ts b/frontend/e2e/history.spec.ts index 8e70b702..9d012d8d 100644 --- a/frontend/e2e/history.spec.ts +++ b/frontend/e2e/history.spec.ts @@ -1,50 +1,72 @@ import { test, expect } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * Document edit history E2E tests. - * Relies on the 'Document creation' and 'Document editing' tests in documents.spec.ts - * having run first (they create and edit "E2E Testbrief (überarbeitet)"). - * Assumes auth setup has run. + * Creates its own test document (two versions) in beforeAll so these tests + * are fully independent of any other spec file. */ -test.describe('Document history panel', () => { - test('history section appears after creating and editing a document', async ({ page }) => { - // Find the document edited in the documents.spec.ts editing test - await page.goto('/?q=E2E+Testbrief'); - await page.waitForSelector('[data-hydrated]'); - const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first(); - const href = await docLink.getAttribute('href'); - await page.goto(href!); - // History section should be present (collapsed by default) +let docPath: string; + +test.describe('Document history panel', () => { + test.beforeAll(async ({ browser }) => { + // Create a fresh browser context that uses the stored auth session + const context = await browser.newContext({ + storageState: path.join(__dirname, '.auth/user.json'), + locale: 'de-DE' + }); + const page = await context.newPage(); + + // 1. Create a new document + await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); + await page.getByLabel('Titel').fill('E2E History Test Dokument'); + await page.getByRole('button', { name: /Speichern/i }).click(); + // Wait for redirect to the new document's UUID-based URL (not /documents/new) + await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/); + docPath = new URL(page.url()).pathname; + + // 2. Edit the document to create a second version + await page.goto(`${docPath}/edit`); + await page.waitForSelector('[data-hydrated]'); + await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)'); + await page.getByRole('button', { name: /Speichern/i }).click(); + await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/); + + await context.close(); + }); + + test('history section appears and shows two versions', async ({ page }) => { + await page.goto(docPath); + await page.waitForSelector('[data-hydrated]'); + const historyToggle = page.getByRole('button', { name: /Verlauf/i }); await expect(historyToggle).toBeVisible(); - - // Expand the history section await historyToggle.click(); - // Should show at least two version entries (created + edited) + // Wait for versions to load (API call happens after panel opens) const versionItems = page.locator('[data-testid="history-version"]'); - await expect(versionItems).toHaveCount(2, { timeout: 5000 }); + await expect(versionItems).toHaveCount(2, { timeout: 10000 }); await page.screenshot({ path: 'test-results/e2e/history-versions-list.png' }); }); test('diff view highlights changed field after title edit', async ({ page }) => { - await page.goto('/?q=E2E+Testbrief'); + await page.goto(docPath); await page.waitForSelector('[data-hydrated]'); - const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first(); - const href = await docLink.getAttribute('href'); - await page.goto(href!); - // Expand history const historyToggle = page.getByRole('button', { name: /Verlauf/i }); await historyToggle.click(); - // Click the second version (the edit) to see its diff + // Wait for versions to load, then click the second one (the edit) const versionItems = page.locator('[data-testid="history-version"]'); + await expect(versionItems.nth(1)).toBeVisible({ timeout: 10000 }); await versionItems.nth(1).click(); - // The diff panel should show the "Titel" field as changed const diffPanel = page.locator('[data-testid="history-diff"]'); await expect(diffPanel).toBeVisible(); await expect(diffPanel.getByText(/Titel/i)).toBeVisible(); @@ -53,34 +75,35 @@ test.describe('Document history panel', () => { }); test('compare mode lets user compare any two versions', async ({ page }) => { - await page.goto('/?q=E2E+Testbrief'); + await page.goto(docPath); await page.waitForSelector('[data-hydrated]'); - const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first(); - const href = await docLink.getAttribute('href'); - await page.goto(href!); - // Expand history const historyToggle = page.getByRole('button', { name: /Verlauf/i }); await historyToggle.click(); - // Switch to compare mode + // Wait for versions to load before the compare button appears + await expect(page.locator('[data-testid="history-version"]').first()).toBeVisible({ + timeout: 10000 + }); + const compareBtn = page.getByRole('button', { name: /Vergleichen/i }); await expect(compareBtn).toBeVisible(); await compareBtn.click(); - // Two version selects should appear const selectA = page.getByLabel(/Version A/i); const selectB = page.getByLabel(/Version B/i); await expect(selectA).toBeVisible(); await expect(selectB).toBeVisible(); - // Apply the comparison + // Select version 1 for A and version 2 for B + await selectA.selectOption({ index: 1 }); + await selectB.selectOption({ index: 2 }); + await page .getByRole('button', { name: /Vergleichen/i }) .last() .click(); - // Diff panel should be visible const diffPanel = page.locator('[data-testid="history-diff"]'); await expect(diffPanel).toBeVisible(); -- 2.49.1 From 4f69457a68c6bbadd60b59ce333279abc91f1e94 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 17:05:20 +0100 Subject: [PATCH 13/13] fix(dev): inject Authorization header from cookie in Vite dev proxy Browser-side fetch('/api/...') calls bypass SvelteKit's handleFetch hook (which adds the Authorization header from the auth_token cookie for SSR). As a result, client-side API calls in the dev server always got a 401. Add a proxy configure hook that extracts the auth_token cookie from incoming requests and sets it as the Authorization header before forwarding to the backend. This makes browser-side fetches (history panel, file preview, etc.) work correctly in dev mode. Co-Authored-By: Claude Sonnet 4.6 --- frontend/vite.config.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4c3e2176..76be0fe1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,7 +13,19 @@ export default defineConfig({ proxy: { '/api': { target: process.env.API_PROXY_TARGET || 'http://localhost:8080', - changeOrigin: true + changeOrigin: true, + // Inject Authorization header from the auth_token cookie so that + // browser-side fetch('/api/...') calls work the same as SSR fetches + // (which go through handleFetch in hooks.server.ts). + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq, req) => { + const cookies = req.headers.cookie ?? ''; + const match = cookies.match(/auth_token=([^;]+)/); + if (match) { + proxyReq.setHeader('Authorization', decodeURIComponent(match[1])); + } + }); + } } } }, -- 2.49.1