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>
This commit is contained in:
@@ -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
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentVersionRepository;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DocumentVersionService {
|
||||
|
||||
private final DocumentVersionRepository versionRepository;
|
||||
private final UserService userService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public void recordVersion(Document doc) {
|
||||
AppUser editor = resolveCurrentUser();
|
||||
String editorName = buildEditorName(editor);
|
||||
UUID editorId = editor != null ? editor.getId() : null;
|
||||
|
||||
String snapshot = serializeSnapshot(doc);
|
||||
List<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user