Compare commits

...

5 Commits

Author SHA1 Message Date
Marcel
4a0d3b3bea test(e2e): add history panel playwright spec
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m19s
CI / Backend Unit Tests (push) Successful in 2m11s
CI / E2E Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Successful in 2m7s
CI / Backend Unit Tests (pull_request) Successful in 2m0s
CI / E2E Tests (pull_request) Failing after 21m58s
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 <noreply@anthropic.com>
2026-03-23 11:59:43 +01:00
Marcel
d4b1a709d7 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 <noreply@anthropic.com>
2026-03-23 11:57:33 +01:00
Marcel
7af49daf9c 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 <noreply@anthropic.com>
2026-03-23 11:41:16 +01:00
Marcel
28256dbd08 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 <noreply@anthropic.com>
2026-03-23 11:30:05 +01:00
Marcel
315b368f88 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 <noreply@anthropic.com>
2026-03-23 11:29:41 +01:00
19 changed files with 1456 additions and 5 deletions

View File

@@ -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<DocumentVersionSummary> 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<Document> getConversation(
@RequestParam UUID senderId,

View File

@@ -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<String> changedFields
) {}

View File

@@ -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;
}

View File

@@ -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<DocumentVersion, UUID> {
List<DocumentVersion> findByDocumentIdOrderBySavedAtAsc(UUID documentId);
Optional<DocumentVersion> findByIdAndDocumentId(UUID id, UUID documentId);
}

View File

@@ -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<String> tagNames) {

View File

@@ -0,0 +1,209 @@
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<DocumentVersion> 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<DocumentVersionSummary> 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<DocumentVersion> previousVersions) {
if (previousVersions.isEmpty()) {
return "[]";
}
DocumentVersion last = previousVersions.get(previousVersions.size() - 1);
try {
Map<String, Object> previousMap = objectMapper.readValue(
last.getSnapshot(), new TypeReference<>() {});
List<String> 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<String> changed, String field, String currentValue,
Map<String, Object> 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<String> changed, Document current, Map<String, Object> 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<String> changed, Document current, Map<String, Object> previousMap) {
Set<String> currentIds = current.getReceivers() != null
? current.getReceivers().stream().map(p -> p.getId().toString()).collect(Collectors.toSet())
: Set.of();
Object prevReceivers = previousMap.get("receivers");
Set<String> 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<String> changed, Document current, Map<String, Object> previousMap) {
Set<String> currentNames = current.getTags() != null
? current.getTags().stream().map(Tag::getName).collect(Collectors.toSet())
: Set.of();
Object prevTags = previousMap.get("tags");
Set<String> 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<String> parseChangedFields(String json) {
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return List.of();
}
}
}

View File

@@ -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);

View File

@@ -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"));
}
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,327 @@
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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersionSummary> 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();
}
}

View File

@@ -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' });
});
});

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { diffWords } from 'diff';
let { data } = $props();
@@ -38,6 +39,237 @@ async function loadFile(id: string) {
isLoading = false;
}
}
// ── History panel ────────────────────────────────────────────────────────────
type VersionSummary = {
id: string;
savedAt: string;
editorName: string;
changedFields: string[];
};
type SnapshotDoc = {
title?: string;
documentDate?: string;
location?: string;
documentLocation?: string;
transcription?: string;
summary?: string;
sender?: { id: string; firstName: string; lastName: string } | null;
receivers?: { id: string; firstName: string; lastName: string }[];
tags?: { id: string; name: string }[];
};
type DiffEntry =
| {
kind: 'text';
field: string;
label: string;
parts: { value: string; added?: boolean; removed?: boolean }[];
}
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
let historyOpen = $state(false);
let historyLoaded = $state(false);
let historyLoading = $state(false);
let versions = $state<VersionSummary[]>([]);
let compareMode = $state(false);
let compareA = $state('');
let compareB = $state('');
let selectedVersionId = $state<string | null>(null);
let diffEntries = $state<DiffEntry[]>([]);
let diffLoading = $state(false);
let noDiff = $state(false);
const fieldLabels: Record<string, () => string> = {
title: m.history_field_title,
documentDate: m.history_field_document_date,
location: m.history_field_location,
documentLocation: m.history_field_document_location,
transcription: m.history_field_transcription,
summary: m.history_field_summary,
sender: m.history_field_sender,
receivers: m.history_field_receivers,
tags: m.history_field_tags
};
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
function parseSnapshot(raw: string): SnapshotDoc {
try {
return JSON.parse(raw) as SnapshotDoc;
} catch {
return {};
}
}
function personLabel(p: { firstName: string; lastName: string }): string {
return `${p.firstName} ${p.lastName}`.trim();
}
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
const entries: DiffEntry[] = [];
for (const field of TEXT_FIELDS) {
const a = older?.[field] ?? '';
const b = newer[field] ?? '';
if (a === b) continue;
const parts = diffWords(a, b);
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
}
for (const field of SCALAR_FIELDS) {
const a = older?.[field] ?? '';
const b = newer[field] ?? '';
if (a === b) continue;
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
}
// sender
const senderA = older?.sender ? personLabel(older.sender) : '';
const senderB = newer.sender ? personLabel(newer.sender) : '';
if (senderA !== senderB) {
const removed = senderA ? [senderA] : [];
const added = senderB ? [senderB] : [];
entries.push({
kind: 'relation',
field: 'sender',
label: fieldLabels['sender'](),
removed,
added
});
}
// receivers
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
entries.push({
kind: 'relation',
field: 'receivers',
label: fieldLabels['receivers'](),
removed: removedReceivers,
added: addedReceivers
});
}
// tags
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
if (removedTags.length > 0 || addedTags.length > 0) {
entries.push({
kind: 'relation',
field: 'tags',
label: fieldLabels['tags'](),
removed: removedTags,
added: addedTags
});
}
return entries;
}
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
const res = await fetch(`/api/documents/${doc.id}/versions/${versionId}`);
if (!res.ok) throw new Error('Failed to fetch version');
const v = await res.json();
return parseSnapshot(v.snapshot);
}
async function toggleHistory() {
historyOpen = !historyOpen;
if (historyOpen && !historyLoaded) {
historyLoading = true;
try {
const res = await fetch(`/api/documents/${doc.id}/versions`);
if (res.ok) {
versions = await res.json();
}
historyLoaded = true;
} catch {
// ignore
} finally {
historyLoading = false;
}
}
}
async function selectVersion(versionId: string) {
if (selectedVersionId === versionId) {
selectedVersionId = null;
diffEntries = [];
noDiff = false;
return;
}
selectedVersionId = versionId;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const idx = versions.findIndex((v) => v.id === versionId);
const newerSnap = await fetchSnapshot(versionId);
const olderSnap = idx + 1 < versions.length ? await fetchSnapshot(versions[idx + 1].id) : null;
const entries = buildDiff(olderSnap, newerSnap);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
async function applyCompare() {
if (!compareA || !compareB || compareA === compareB) return;
selectedVersionId = null;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
// compareA is the "older" baseline, compareB is "newer"
const entries = buildDiff(snapA, snapB);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
function formatDateTime(iso: string): string {
try {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(iso));
} catch {
return iso;
}
}
function versionLabel(v: VersionSummary, index: number): string {
return `Version ${versions.length - index}${v.editorName}${formatDateTime(v.savedAt)}`;
}
</script>
<div class="flex h-screen flex-col bg-white">
@@ -346,6 +578,217 @@ async function loadFile(id: string) {
</div>
{/if}
<!-- 4. HISTORY GROUP -->
<div>
<div class="flex items-center justify-between border-b border-brand-sand pb-2">
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
{m.history_section_title()}
</h3>
<button
onclick={toggleHistory}
class="flex items-center gap-1 rounded p-1 text-gray-400 transition hover:bg-brand-sand/50 hover:text-brand-navy"
aria-expanded={historyOpen}
aria-label={m.history_section_title()}
>
<svg
class="h-4 w-4 transition-transform duration-200 {historyOpen ? 'rotate-180' : ''}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{#if historyOpen}
<div class="mt-4 space-y-3">
{#if historyLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
{:else if versions.length === 0}
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p>
{:else}
<!-- Compare mode toggle -->
<div class="flex justify-end">
<button
onclick={() => {
compareMode = !compareMode;
diffEntries = [];
noDiff = false;
selectedVersionId = null;
}}
class="font-sans text-xs font-medium transition {compareMode
? 'text-brand-navy underline'
: 'text-gray-400 hover:text-brand-navy'}"
>
{m.history_compare_mode()}
</button>
</div>
{#if compareMode}
<!-- Compare selects -->
<div class="space-y-2">
<div>
<label
for="compare-a"
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
>{m.history_compare_select_a()}</label
>
<select
id="compare-a"
bind:value={compareA}
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<div>
<label
for="compare-b"
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
>{m.history_compare_select_b()}</label
>
<select
id="compare-b"
bind:value={compareB}
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<button
onclick={applyCompare}
disabled={!compareA || !compareB || compareA === compareB}
class="w-full rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-brand-navy/80 disabled:cursor-not-allowed disabled:opacity-40"
>
{m.history_compare_apply()}
</button>
</div>
{:else}
<!-- Version list -->
<ul class="divide-y divide-brand-sand">
{#each versions as v, i (v.id)}
<li>
<button
onclick={() => selectVersion(v.id)}
data-testid="history-version"
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId ===
v.id
? 'border-l-2 border-brand-mint pl-2'
: 'pl-0'}"
>
<div class="flex items-baseline justify-between gap-2">
<span class="font-sans text-xs font-medium text-brand-navy">
Version {versions.length - i}
</span>
<span class="font-sans text-[10px] text-gray-400">
{formatDateTime(v.savedAt)}
</span>
</div>
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span>
{#if v.changedFields && v.changedFields.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each v.changedFields as field (field)}
<span
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase"
>
{fieldLabels[field] ? fieldLabels[field]() : field}
</span>
{/each}
</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
<!-- Diff panel -->
{#if diffLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span
>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
<p class="truncate">ID: {doc.id}</p>

View File

@@ -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"