diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java index 83645759..0f6f5c14 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AdminController.java @@ -1,7 +1,10 @@ package org.raddatz.familienarchiv.controller; +import org.raddatz.familienarchiv.dto.BackfillResult; 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.MassImportService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -18,6 +21,8 @@ import lombok.RequiredArgsConstructor; public class AdminController { private final MassImportService massImportService; + private final DocumentService documentService; + private final DocumentVersionService documentVersionService; @PostMapping("/trigger-import") public ResponseEntity triggerMassImport() { @@ -29,4 +34,11 @@ public class AdminController { public ResponseEntity importStatus() { return ResponseEntity.ok(massImportService.getStatus()); } + + @PostMapping("/backfill-versions") + public ResponseEntity backfillVersions() { + int count = documentVersionService.backfillMissingVersions( + documentService.getDocumentsWithoutVersions()); + return ResponseEntity.ok(new BackfillResult(count)); + } } 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/dto/BackfillResult.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/BackfillResult.java new file mode 100644 index 00000000..3178d68e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/BackfillResult.java @@ -0,0 +1,7 @@ +package org.raddatz.familienarchiv.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record BackfillResult( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count +) {} 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/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java index 370ceb29..d2d6047b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java @@ -34,6 +34,9 @@ public interface DocumentRepository extends JpaRepository, JpaSp List findByTags_Id(UUID tagId); + @Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)") + List findDocumentsWithoutVersions(); + @Query("SELECT DISTINCT d FROM Document d " + "JOIN d.receivers r " + "WHERE " + 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/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 2c8f5ce1..521b8199 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) { @@ -252,6 +257,10 @@ public class DocumentService { .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); } + public List getDocumentsWithoutVersions() { + return documentRepository.findDocumentsWithoutVersions(); + } + public List getDocumentsBySender(UUID senderId) { return documentRepository.findBySenderId(senderId); } 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..a0092eec --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentVersionService.java @@ -0,0 +1,229 @@ +package org.raddatz.familienarchiv.service; + +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; +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()); + } + + @Transactional + public int backfillMissingVersions(List docs) { + int count = 0; + for (Document doc : docs) { + List existing = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId()); + if (!existing.isEmpty()) continue; + LocalDateTime savedAt = doc.getCreatedAt() != null ? doc.getCreatedAt() : LocalDateTime.now(); + versionRepository.save(DocumentVersion.builder() + .documentId(doc.getId()) + .savedAt(savedAt) + .editorId(null) + .editorName("Datenimport") + .snapshot(serializeSnapshot(doc)) + .changedFields("[]") + .build()); + count++; + } + return count; + } + + 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/controller/AdminControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java new file mode 100644 index 00000000..b37eb3d7 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AdminControllerTest.java @@ -0,0 +1,61 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.model.Document; +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.MassImportService; +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.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 static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; +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(AdminController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class AdminControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean MassImportService massImportService; + @MockitoBean DocumentService documentService; + @MockitoBean DocumentVersionService documentVersionService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + @Test + void backfillVersions_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/admin/backfill-versions")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(roles = "USER") + void backfillVersions_returns403_whenNotAdmin() throws Exception { + mockMvc.perform(post("/api/admin/backfill-versions")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN") + void backfillVersions_returns200_withCount_whenAdmin() throws Exception { + when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build())); + when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1); + + mockMvc.perform(post("/api/admin/backfill-versions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").value(1)); + } +} 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)); + } } 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..90b20dd9 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentVersionServiceTest.java @@ -0,0 +1,397 @@ +package org.raddatz.familienarchiv.service; + +import tools.jackson.databind.ObjectMapper; +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() { + versionService = new DocumentVersionService(versionRepository, userService, new ObjectMapper()); + } + + @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(); + 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(); + 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(); + 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(); + 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(); + 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); + } + + // ─── backfillMissingVersions ────────────────────────────────────────────── + + @Test + void backfill_createsVersion_withEditorNameDatenimport() { + Document doc = minimalDocument(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.backfillMissingVersions(List.of(doc)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getEditorName()).isEqualTo("Datenimport"); + } + + @Test + void backfill_usesDocumentCreatedAt_asSavedAt() { + LocalDateTime createdAt = LocalDateTime.of(2020, 3, 15, 10, 0); + Document doc = Document.builder() + .id(UUID.randomUUID()).title("T").createdAt(createdAt).build(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.backfillMissingVersions(List.of(doc)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getSavedAt()).isEqualTo(createdAt); + } + + @Test + void backfill_setsChangedFieldsEmpty() { + Document doc = minimalDocument(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + versionService.backfillMissingVersions(List.of(doc)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocumentVersion.class); + verify(versionRepository).save(captor.capture()); + assertThat(captor.getValue().getChangedFields()).isEqualTo("[]"); + } + + @Test + void backfill_skipsDocuments_thatAlreadyHaveVersions() { + Document doc = minimalDocument(); + DocumentVersion existing = DocumentVersion.builder() + .id(UUID.randomUUID()).documentId(doc.getId()).snapshot("{}") + .changedFields("[]").editorName("user").savedAt(LocalDateTime.now()).build(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of(existing)); + + int count = versionService.backfillMissingVersions(List.of(doc)); + + verify(versionRepository, never()).save(any()); + assertThat(count).isZero(); + } + + @Test + void backfill_returnsCountOfCreatedVersions() { + Document d1 = minimalDocument(); + Document d2 = minimalDocument(); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d1.getId())).thenReturn(List.of()); + when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d2.getId())).thenReturn(List.of()); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + int count = versionService.backfillMissingVersions(List.of(d1, d2)); + + assertThat(count).isEqualTo(2); + } + + // ─── 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(); + } +} diff --git a/frontend/e2e/history.spec.ts b/frontend/e2e/history.spec.ts new file mode 100644 index 00000000..9d012d8d --- /dev/null +++ b/frontend/e2e/history.spec.ts @@ -0,0 +1,112 @@ +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. + * Creates its own test document (two versions) in beforeAll so these tests + * are fully independent of any other spec file. + */ + +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(); + await historyToggle.click(); + + // 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: 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(docPath); + await page.waitForSelector('[data-hydrated]'); + + const historyToggle = page.getByRole('button', { name: /Verlauf/i }); + await historyToggle.click(); + + // 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(); + + 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(docPath); + await page.waitForSelector('[data-hydrated]'); + + const historyToggle = page.getByRole('button', { name: /Verlauf/i }); + await historyToggle.click(); + + // 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(); + + const selectA = page.getByLabel(/Version A/i); + const selectB = page.getByLabel(/Version B/i); + await expect(selectA).toBeVisible(); + await expect(selectB).toBeVisible(); + + // 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(); + + 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/messages/de.json b/frontend/messages/de.json index e614ac93..017d4a29 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -215,5 +215,30 @@ "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", + "admin_tab_system": "System", + "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.", + "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 79d96300..23f677d1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -215,5 +215,30 @@ "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", + "admin_tab_system": "System", + "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.", + "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 f12d6e40..595a0dba 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -215,5 +215,30 @@ "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", + "admin_tab_system": "Sistema", + "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.", + "comp_expandable_show_more": "Mostrar más", + "comp_expandable_show_less": "Mostrar menos" } 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/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/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 46539816..f756e81f 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -228,6 +228,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/backfill-versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["backfillVersions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/groups/{id}": { parameters: { query?: never; @@ -308,6 +324,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 +564,31 @@ export interface components { /** Format: date-time */ startedAt?: string; }; + BackfillResult: { + /** Format: int32 */ + count: number; + }; + 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; @@ -1053,6 +1126,26 @@ export interface operations { }; }; }; + backfillVersions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BackfillResult"]; + }; + }; + }; + }; deleteGroup: { parameters: { query?: never; @@ -1189,6 +1282,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/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 841476cc..fdfc50a3 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -9,6 +9,8 @@ let activeTab = $state('users'); let editingTagId: string | null = $state(null); let editingTagName = $state(''); let editingGroupId: string | null = $state(null); +let backfillResult: number | null = $state(null); +let backfillLoading = $state(false); const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION']; @@ -29,6 +31,20 @@ function startEditGroup(id: string) { function cancelEditGroup() { editingGroupId = null; } + +async function backfillVersions() { + backfillLoading = true; + backfillResult = null; + try { + const res = await fetch('/api/admin/backfill-versions', { method: 'POST' }); + if (res.ok) { + const data = await res.json(); + backfillResult = data.count; + } + } finally { + backfillLoading = false; + } +}
@@ -58,6 +74,13 @@ function cancelEditGroup() { : 'text-gray-500 hover:text-brand-navy'}" onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()} +
@@ -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} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 545fc804..75a03373 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,6 +1,8 @@
@@ -322,11 +593,7 @@ async function loadFile(id: string) { {m.doc_label_summary()} -
- {doc.summary} -
+
{/if} @@ -335,17 +602,224 @@ async function loadFile(id: string) { {m.form_label_transcription()} -
- {doc.transcription} -
+ {/if} {/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}

@@ -401,14 +875,14 @@ async function loadFile(id: string) {
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')} {:else if fileUrl}
{m.doc_image_alt()} 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])); + } + }); + } } } }, 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"