feat(#38): document edit history with diff and compare mode #52

Merged
marcel merged 13 commits from feat/38-document-edit-history into main 2026-03-23 17:27:57 +01:00
26 changed files with 1846 additions and 18 deletions

View File

@@ -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<MassImportService.ImportStatus> triggerMassImport() {
@@ -29,4 +34,11 @@ public class AdminController {
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
return ResponseEntity.ok(massImportService.getStatus());
}
@PostMapping("/backfill-versions")
public ResponseEntity<BackfillResult> backfillVersions() {
int count = documentVersionService.backfillMissingVersions(
documentService.getDocumentsWithoutVersions());
return ResponseEntity.ok(new BackfillResult(count));
}
}

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,7 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
public record BackfillResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count
) {}

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

@@ -34,6 +34,9 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
List<Document> findByTags_Id(UUID tagId);
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
List<Document> findDocumentsWithoutVersions();
@Query("SELECT DISTINCT d FROM Document d " +
"JOIN d.receivers r " +
"WHERE " +

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) {
@@ -252,6 +257,10 @@ public class DocumentService {
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
}
public List<Document> getDocumentsWithoutVersions() {
return documentRepository.findDocumentsWithoutVersions();
}
public List<Document> getDocumentsBySender(UUID senderId) {
return documentRepository.findBySenderId(senderId);
}

View File

@@ -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<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());
}
@Transactional
public int backfillMissingVersions(List<Document> docs) {
int count = 0;
for (Document doc : docs) {
List<DocumentVersion> 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<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

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

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,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<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);
}
// ─── 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<DocumentVersion> 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<DocumentVersion> 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<DocumentVersion> 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();
}
}

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,33 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { text, maxLines = 10 }: { text: string; maxLines?: number } = $props();
let expanded = $state(false);
let el = $state<HTMLElement | undefined>(undefined);
let isClamped = $state(false);
$effect(() => {
if (el && !expanded) {
isClamped = el.scrollHeight > el.clientHeight;
}
});
</script>
<div>
<div
bind:this={el}
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
>
{text}
</div>
{#if isClamped || expanded}
<button
onclick={() => (expanded = !expanded)}
class="mt-2 font-sans text-xs text-gray-400 transition hover:text-brand-navy"
>
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
</button>
{/if}
</div>

View File

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

View File

@@ -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;
}
}
</script>
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
@@ -58,6 +74,13 @@ function cancelEditGroup() {
: 'text-gray-500 hover:text-brand-navy'}"
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
>
<button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
'system'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button
>
</div>
</div>
@@ -495,5 +518,22 @@ function cancelEditGroup() {
</form>
</div>
</div>
{:else if activeTab === 'system'}
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-1 text-lg font-bold text-gray-700">{m.admin_system_backfill_heading()}</h2>
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_description()}</p>
<button
onclick={backfillVersions}
disabled={backfillLoading}
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-brand-mint hover:text-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
>
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
</button>
{#if backfillResult !== null}
<p class="mt-4 text-sm font-medium text-brand-navy">
{m.admin_system_backfill_success({ count: backfillResult })}
</p>
{/if}
</div>
{/if}
</div>

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { diffWords } from 'diff';
import ExpandableText from '$lib/components/ExpandableText.svelte';
let { data } = $props();
@@ -38,6 +40,275 @@ 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();
}
const DIFF_CONTEXT_WORDS = 4;
type DiffPart = { value: string; added?: boolean; removed?: boolean };
function trimContextParts(parts: DiffPart[]): DiffPart[] {
return parts.flatMap((part, i) => {
if (part.added || part.removed) return [part];
const tokens = part.value.split(/(\s+)/).filter(Boolean);
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
function keepFirst(n: number): string {
let count = 0;
const out: string[] = [];
for (const t of tokens) {
out.push(t);
if (/\S/.test(t) && ++count >= n) break;
}
return out.join('');
}
function keepLast(n: number): string {
let count = 0;
const out: string[] = [];
for (const t of [...tokens].reverse()) {
out.unshift(t);
if (/\S/.test(t) && ++count >= n) break;
}
return out.join('');
}
const isFirst = i === 0;
const isLast = i === parts.length - 1;
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
});
}
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
const entries: DiffEntry[] = [];
for (const field of TEXT_FIELDS) {
const a = older?.[field] ?? '';
const b = newer[field] ?? '';
if (a === b) continue;
const parts = trimContextParts(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 > 0 ? 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 ${index + 1}${v.editorName}${formatDateTime(v.savedAt)}`;
}
</script>
<div class="flex h-screen flex-col bg-white">
@@ -322,11 +593,7 @@ async function loadFile(id: string) {
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.doc_label_summary()}</span
>
<div
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
>
{doc.summary}
</div>
<ExpandableText text={doc.summary} />
</div>
{/if}
@@ -335,17 +602,224 @@ async function loadFile(id: string) {
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.form_label_transcription()}</span
>
<div
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
>
{doc.transcription}
</div>
<ExpandableText text={doc.transcription} />
</div>
{/if}
</div>
</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 {i + 1}
</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>
@@ -401,14 +875,14 @@ async function loadFile(id: string) {
</div>
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
<iframe
src={fileUrl}
src="{fileUrl}#zoom=page-width"
title={m.doc_preview_iframe_title()}
class="h-full w-full border-none bg-white"
></iframe>
{:else if fileUrl}
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
<img
src={fileUrl}
src="{fileUrl}#zoom=page-width"
alt={m.doc_image_alt()}
class="max-h-full max-w-full object-contain shadow-2xl"
/>

View File

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

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"