merge(frontend): resolve conflicts with main — integrate fileHash feature into panel architecture
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m21s
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Failing after 28m37s
CI / Unit & Component Tests (push) Successful in 2m26s
CI / Backend Unit Tests (push) Successful in 2m14s
CI / E2E Tests (push) Has started running

Keep the new bottom-panel / AnnotationSidePanel architecture from this branch
while pulling in the documentFileHash / visibleAnnotations filter that was added
on main. Thread documentFileHash through DocumentViewer so outdated-annotation
filtering works end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #63.
This commit is contained in:
Marcel
2026-03-25 11:20:48 +01:00
26 changed files with 1139 additions and 61 deletions

View File

@@ -41,4 +41,10 @@ public class AdminController {
documentService.getDocumentsWithoutVersions()); documentService.getDocumentsWithoutVersions());
return ResponseEntity.ok(new BackfillResult(count)); return ResponseEntity.ok(new BackfillResult(count));
} }
@PostMapping("/backfill-file-hashes")
public ResponseEntity<BackfillResult> backfillFileHashes() {
int count = documentService.backfillFileHashes();
return ResponseEntity.ok(new BackfillResult(count));
}
} }

View File

@@ -4,10 +4,12 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService; import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@@ -23,6 +25,7 @@ import java.util.UUID;
public class AnnotationController { public class AnnotationController {
private final AnnotationService annotationService; private final AnnotationService annotationService;
private final DocumentService documentService;
private final UserService userService; private final UserService userService;
@GetMapping @GetMapping
@@ -38,7 +41,8 @@ public class AnnotationController {
@RequestBody CreateAnnotationDTO dto, @RequestBody CreateAnnotationDTO dto,
Authentication authentication) { Authentication authentication) {
UUID userId = resolveUserId(authentication); UUID userId = resolveUserId(authentication);
return annotationService.createAnnotation(documentId, dto, userId); Document doc = documentService.getDocumentById(documentId);
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
} }
@DeleteMapping("/{annotationId}") @DeleteMapping("/{annotationId}")

View File

@@ -39,6 +39,10 @@ public class Document {
@Column(name = "content_type") @Column(name = "content_type")
private String contentType; private String contentType;
// SHA-256 hash of the uploaded file — used to link annotations to a file version
@Column(name = "file_hash", length = 64)
private String fileHash;
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf") // Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
@Column(name = "original_filename", nullable = false) @Column(name = "original_filename", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -49,6 +49,9 @@ public class DocumentAnnotation {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String color; private String color;
@Column(name = "file_hash", length = 64)
private String fileHash;
@Column(name = "created_by") @Column(name = "created_by")
private UUID createdBy; private UUID createdBy;

View File

@@ -14,4 +14,6 @@ public interface AnnotationRepository extends JpaRepository<DocumentAnnotation,
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber); List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId); Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
List<DocumentAnnotation> findByDocumentIdAndFileHashIsNull(UUID documentId);
} }

View File

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

View File

@@ -23,7 +23,7 @@ public class AnnotationService {
} }
@Transactional @Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) { public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
List<DocumentAnnotation> existing = List<DocumentAnnotation> existing =
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber()); annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
@@ -41,6 +41,7 @@ public class AnnotationService {
.width(dto.getWidth()) .width(dto.getWidth())
.height(dto.getHeight()) .height(dto.getHeight())
.color(dto.getColor()) .color(dto.getColor())
.fileHash(fileHash)
.createdBy(userId) .createdBy(userId)
.build(); .build();
@@ -61,6 +62,14 @@ public class AnnotationService {
annotationRepository.delete(annotation); annotationRepository.delete(annotation);
} }
@Transactional
public void backfillAnnotationFileHashForDocument(UUID documentId, String fileHash) {
annotationRepository.findByDocumentIdAndFileHashIsNull(documentId).forEach(a -> {
a.setFileHash(fileHash);
annotationRepository.save(a);
});
}
// ─── private helpers ────────────────────────────────────────────────────── // ─── private helpers ──────────────────────────────────────────────────────
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) { private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {

View File

@@ -18,6 +18,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
@@ -38,6 +40,7 @@ public class DocumentService {
private final FileService fileService; private final FileService fileService;
private final TagService tagService; private final TagService tagService;
private final DocumentVersionService documentVersionService; private final DocumentVersionService documentVersionService;
private final AnnotationService annotationService;
/** /**
* Lädt eine Datei hoch. * Lädt eine Datei hoch.
@@ -64,10 +67,11 @@ public class DocumentService {
} }
// 2. Delegate Storage to FileService // 2. Delegate Storage to FileService
String s3Key = fileService.uploadFile(file, originalFilename); FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
// 3. Update Database // 3. Update Database
document.setFilePath(s3Key); document.setFilePath(upload.s3Key());
document.setFileHash(upload.fileHash());
document.setContentType(file.getContentType()); document.setContentType(file.getContentType());
if (document.getStatus() == DocumentStatus.PLACEHOLDER) { if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
document.setStatus(DocumentStatus.UPLOADED); document.setStatus(DocumentStatus.UPLOADED);
@@ -120,8 +124,9 @@ public class DocumentService {
// Datei // Datei
if (file != null && !file.isEmpty()) { if (file != null && !file.isEmpty()) {
String s3Key = fileService.uploadFile(file, file.getOriginalFilename()); FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(s3Key); doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
doc.setContentType(file.getContentType()); doc.setContentType(file.getContentType());
doc.setStatus(DocumentStatus.UPLOADED); doc.setStatus(DocumentStatus.UPLOADED);
} }
@@ -170,12 +175,9 @@ public class DocumentService {
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde) // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
if (newFile != null && !newFile.isEmpty()) { if (newFile != null && !newFile.isEmpty()) {
// Alte Datei könnte man hier theoretisch löschen (optional) FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(upload.s3Key());
// Neue Datei hochladen doc.setFileHash(upload.fileHash());
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(s3Key);
doc.setOriginalFilename(newFile.getOriginalFilename()); doc.setOriginalFilename(newFile.getOriginalFilename());
doc.setContentType(newFile.getContentType()); doc.setContentType(newFile.getContentType());
doc.setStatus(DocumentStatus.UPLOADED); doc.setStatus(DocumentStatus.UPLOADED);
@@ -283,4 +285,39 @@ public class DocumentService {
}); });
tagService.delete(tagId); tagService.delete(tagId);
} }
@Transactional
public int backfillFileHashes() {
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
int count = 0;
for (Document doc : docs) {
try {
byte[] bytes = fileService.downloadFileBytes(doc.getFilePath());
String hash = sha256Hex(bytes);
doc.setFileHash(hash);
documentRepository.save(doc);
annotationService.backfillAnnotationFileHashForDocument(doc.getId(), hash);
count++;
} catch (Exception e) {
log.warn("Failed to backfill hash for document {}: {}", doc.getId(), e.getMessage());
}
}
return count;
}
// ─── private helpers ──────────────────────────────────────────────────────
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
StringBuilder sb = new StringBuilder(64);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
} }

View File

@@ -13,6 +13,9 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID; import java.util.UUID;
@Service @Service
@@ -29,10 +32,14 @@ public class FileService {
} }
/** /**
* Uploads a file to S3/MinIO and returns the generated object key. * Uploads a file to S3/MinIO.
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
* hash of the file content. The hash is used to link annotations to the
* specific file version they were created against.
*/ */
public String uploadFile(MultipartFile file, String originalFilename) throws IOException { public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
// Generate secure unique path: "documents/UUID_filename" byte[] bytes = file.getBytes();
String fileHash = sha256Hex(bytes);
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename; String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
try { try {
@@ -42,11 +49,10 @@ public class FileService {
.contentType(file.getContentType()) .contentType(file.getContentType())
.build(); .build();
s3Client.putObject(putObjectRequest, s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
log.info("Uploaded file to S3: {}", s3Key); log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
return s3Key; return new UploadResult(s3Key, fileHash);
} catch (S3Exception e) { } catch (S3Exception e) {
log.error("S3 Upload Error", e); log.error("S3 Upload Error", e);
throw new IOException("Failed to upload file to storage", e); throw new IOException("Failed to upload file to storage", e);
@@ -58,32 +64,72 @@ public class FileService {
* Returns a wrapper containing the stream and content type. * Returns a wrapper containing the stream and content type.
*/ */
public S3FileDownload downloadFile(String s3Key) { public S3FileDownload downloadFile(String s3Key) {
try { try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder() GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName) .bucket(bucketName)
.key(s3Key) .key(s3Key)
.build(); .build();
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest); ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
// Use whatever content type S3 has stored (set at upload time) String contentType = s3Object.response().contentType();
String contentType = s3Object.response().contentType(); if (contentType == null || contentType.isBlank()) {
if (contentType == null || contentType.isBlank()) { contentType = "application/octet-stream";
contentType = "application/octet-stream"; }
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
} }
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
} }
}
// Helper Record to carry the stream and metadata back to the controller /**
* Downloads a file from S3/MinIO and returns its raw bytes.
* Used for hash backfill — callers are responsible for not calling this on large files unnecessarily.
*/
public byte[] downloadFileBytes(String s3Key) throws IOException {
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
try (InputStream in = s3Client.getObject(getObjectRequest)) {
return in.readAllBytes();
}
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new IOException("Failed to download file from storage: " + e.getMessage(), e);
}
}
// ─── private helpers ──────────────────────────────────────────────────────
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
StringBuilder sb = new StringBuilder(64);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
// ─── result types ─────────────────────────────────────────────────────────
/** Carries the S3 object key and the content hash back to the caller. */
public record UploadResult(String s3Key, String fileHash) {}
/** Carries the download stream and content type. */
public record S3FileDownload(InputStreamResource resource, String contentType) {} public record S3FileDownload(InputStreamResource resource, String contentType) {}
// Custom Exception
public static class StorageFileNotFoundException extends RuntimeException { public static class StorageFileNotFoundException extends RuntimeException {
public StorageFileNotFoundException(String message) { super(message); } public StorageFileNotFoundException(String message) { super(message); }
} }

View File

@@ -0,0 +1,7 @@
-- Add content-based file hash to documents for annotation versioning
ALTER TABLE documents
ADD COLUMN file_hash VARCHAR(64);
-- Each annotation remembers which file version it was created against
ALTER TABLE document_annotations
ADD COLUMN file_hash VARCHAR(64);

View File

@@ -58,4 +58,29 @@ class AdminControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(1)); .andExpect(jsonPath("$.count").value(1));
} }
// ─── POST /api/admin/backfill-file-hashes ──────────────────────────────────
@Test
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN")
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
when(documentService.backfillFileHashes()).thenReturn(3);
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3));
}
} }

View File

@@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService; import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
@@ -36,6 +38,7 @@ class AnnotationControllerTest {
@Autowired MockMvc mockMvc; @Autowired MockMvc mockMvc;
@MockitoBean AnnotationService annotationService; @MockitoBean AnnotationService annotationService;
@MockitoBean DocumentService documentService;
@MockitoBean UserService userService; @MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean CustomUserDetailsService customUserDetailsService;
@@ -85,7 +88,8 @@ class AnnotationControllerTest {
DocumentAnnotation saved = DocumentAnnotation.builder() DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1) .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build(); .x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations") mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -97,7 +101,8 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns409_whenOverlap() throws Exception { void createAnnotation_returns409_whenOverlap() throws Exception {
when(annotationService.createAnnotation(any(), any(), any())) when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any()))
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap")); .thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")

View File

@@ -44,7 +44,7 @@ class AnnotationServiceTest {
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)) when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
.thenReturn(List.of(existing)); .thenReturn(List.of(existing));
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId)) assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT)); .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
@@ -63,7 +63,7 @@ class AnnotationServiceTest {
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build(); .x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
when(annotationRepository.save(any())).thenReturn(saved); when(annotationRepository.save(any())).thenReturn(saved);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId); DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result).isEqualTo(saved); assertThat(result).isEqualTo(saved);
verify(annotationRepository).save(any()); verify(annotationRepository).save(any());
@@ -117,6 +117,35 @@ class AnnotationServiceTest {
verify(annotationRepository).delete(annotation); verify(annotationRepository).delete(annotation);
} }
@Test
void createAnnotation_setsFileHash_whenProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
String fileHash = "abc123";
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
assertThat(result.getFileHash()).isEqualTo(fileHash);
}
@Test
void createAnnotation_setsNullFileHash_whenNoneProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result.getFileHash()).isNull();
}
// ─── listAnnotations ────────────────────────────────────────────────────── // ─── listAnnotations ──────────────────────────────────────────────────────
@Test @Test
@@ -128,4 +157,30 @@ class AnnotationServiceTest {
assertThat(annotationService.listAnnotations(docId)).containsExactly(a); assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
} }
// ─── backfillAnnotationFileHashForDocument ────────────────────────────────
@Test
void backfillAnnotationFileHashForDocument_setsHashOnAnnotationsWithNullHash() {
UUID docId = UUID.randomUUID();
String hash = "abc123";
DocumentAnnotation a = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).build();
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of(a));
annotationService.backfillAnnotationFileHashForDocument(docId, hash);
assertThat(a.getFileHash()).isEqualTo(hash);
verify(annotationRepository).save(a);
}
@Test
void backfillAnnotationFileHashForDocument_doesNothingWhenNoAnnotations() {
UUID docId = UUID.randomUUID();
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of());
annotationService.backfillAnnotationFileHashForDocument(docId, "hash");
verify(annotationRepository, never()).save(any());
}
} }

View File

@@ -21,6 +21,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -31,6 +32,7 @@ class DocumentServiceTest {
@Mock FileService fileService; @Mock FileService fileService;
@Mock TagService tagService; @Mock TagService tagService;
@Mock DocumentVersionService documentVersionService; @Mock DocumentVersionService documentVersionService;
@Mock AnnotationService annotationService;
@InjectMocks DocumentService documentService; @InjectMocks DocumentService documentService;
// ─── getDocumentById ────────────────────────────────────────────────────── // ─── getDocumentById ──────────────────────────────────────────────────────
@@ -135,6 +137,48 @@ class DocumentServiceTest {
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc); assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
} }
// ─── file hash propagation ───────────────────────────────────────────────
@Test
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(savedDoc);
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
documentService.createDocument(dto, file);
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
}
@Test
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Alt").originalFilename("old.pdf")
.status(DocumentStatus.UPLOADED).build();
org.springframework.mock.web.MockMultipartFile newFile =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
when(documentRepository.save(any())).thenReturn(existing);
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
}
// ─── versioning ─────────────────────────────────────────────────────────── // ─── versioning ───────────────────────────────────────────────────────────
@Test @Test
@@ -167,4 +211,59 @@ class DocumentServiceTest {
verify(documentVersionService).recordVersion(any(Document.class)); verify(documentVersionService).recordVersion(any(Document.class));
} }
// ─── backfillFileHashes ───────────────────────────────────────────────────
@Test
void backfillFileHashes_skipsDocumentsWithNoFilePath() throws Exception {
Document noFile = Document.builder().id(UUID.randomUUID()).build();
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of());
int count = documentService.backfillFileHashes();
assertThat(count).isZero();
verify(fileService, never()).downloadFileBytes(any());
}
@Test
void backfillFileHashes_computesHashAndSavesDocument() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.backfillFileHashes();
assertThat(doc.getFileHash()).isNotNull().hasSize(64);
verify(documentRepository).save(doc);
}
@Test
void backfillFileHashes_propagatesHashToAnnotations() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.backfillFileHashes();
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
}
@Test
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
Document doc1 = Document.builder().id(id1).filePath("documents/a.pdf").build();
Document doc2 = Document.builder().id(id2).filePath("documents/b.pdf").build();
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc1, doc2));
when(fileService.downloadFileBytes(any())).thenReturn(new byte[]{1});
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
int count = documentService.backfillFileHashes();
assertThat(count).isEqualTo(2);
}
} }

View File

@@ -0,0 +1,85 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockMultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class FileServiceTest {
private S3Client s3Client;
private FileService fileService;
@BeforeEach
void setUp() {
s3Client = mock(S3Client.class);
fileService = new FileService(s3Client, "test-bucket");
}
@Test
void uploadFile_returnsS3Key() throws IOException {
MockMultipartFile file = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
assertThat(result.s3Key()).startsWith("documents/");
assertThat(result.s3Key()).endsWith("_test.pdf");
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
byte[] content = "hello pdf content".getBytes();
MockMultipartFile file = new MockMultipartFile(
"file", "doc.pdf", "application/pdf", content);
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
// Compute expected hash independently
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content);
StringBuilder expected = new StringBuilder();
for (byte b : hashBytes) {
expected.append(String.format("%02x", b));
}
assertThat(result.fileHash()).isEqualTo(expected.toString());
}
@Test
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
MockMultipartFile file1 = new MockMultipartFile(
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
MockMultipartFile file2 = new MockMultipartFile(
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
}
@Test
void uploadFile_sameContents_produceSameHash() throws IOException {
byte[] content = new byte[]{10, 20, 30};
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
}
}

View File

@@ -216,3 +216,35 @@ test.describe('Admin — tag management', () => {
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' }); await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
}); });
}); });
// ─── System tab — backfill file hashes ────────────────────────────────────────
test.describe('Admin system tab — backfill file hashes', () => {
test('admin triggers file hash backfill and sees success message', async ({ request, page }) => {
test.setTimeout(60_000);
// Create a document via API so there is at least one without a hash
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Backfill Hash Test' }
});
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
// Navigate to System tab
await page.getByRole('button', { name: /system/i }).click();
// Click the backfill hashes button
const btn = page.getByRole('button', { name: /datei-hashes berechnen/i });
await expect(btn).toBeVisible();
await btn.click();
// Success message must appear (count >= 0)
await expect(page.locator('text=/\\d+ Dokumente wurden aktualisiert/i')).toBeVisible({
timeout: 15000
});
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' });
});
});

View File

@@ -347,13 +347,142 @@ test.describe('PDF annotations — admin', () => {
}); });
}); });
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
test.describe('PDF annotations — file hash versioning', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
test.setTimeout(90_000);
// 1. Create document and upload original PDF
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Hash Test — version' }
});
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
const doc = await createRes.json();
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
// 2. Create an annotation via API
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#ff0000' }
});
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
// 3. Verify annotation appears before re-upload
await page.goto(`${baseURL}/documents/${doc.id}`);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
// 4. Upload a different file (different hash)
const reuploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal2.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE2)
}
}
});
if (!reuploadRes.ok()) throw new Error(`Re-upload failed: ${reuploadRes.status()}`);
// 5. Reload — annotation must be hidden and notice shown
await page.reload();
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 });
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
timeout: 5000
});
await page.screenshot({ path: 'test-results/e2e/annotation-hidden-after-reupload.png' });
});
test('annotations reappear after re-uploading the original file', async ({ page, request }) => {
test.setTimeout(90_000);
// 1. Create document and upload original PDF
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Hash Test — restore' }
});
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
const doc = await createRes.json();
const originalBytes = fs.readFileSync(PDF_FIXTURE);
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
}
});
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
// 2. Create annotation
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#0000ff' }
});
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
// 3. Replace with different file
const replaceRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal2.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE2)
}
}
});
if (!replaceRes.ok()) throw new Error(`Replace failed: ${replaceRes.status()}`);
// 4. Re-upload original file (restoring the hash)
const restoreRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
}
});
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
// 5. Verify annotation reappears and notice is gone
await page.goto(`${baseURL}/documents/${doc.id}`);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/annotation-restored.png' });
});
});
// ─── PDF Annotations (read-only user) ───────────────────────────────────────── // ─── PDF Annotations (read-only user) ─────────────────────────────────────────
test.describe('PDF annotations — read-only user', () => { test.describe('PDF annotations — read-only user', () => {
// Isolated session — does not share the admin storage state // Isolated session — does not share the admin storage state
test.use({ storageState: { cookies: [], origins: [] } }); test.use({ storageState: { cookies: [], origins: [] } });
test('read-only user sees a disabled Annotieren button', async ({ page }) => { test('read-only user does not see the Annotieren button', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('Benutzername').fill('reader'); await page.getByLabel('Benutzername').fill('reader');
@@ -365,12 +494,10 @@ test.describe('PDF annotations — read-only user', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`); await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
// Wait for the PDF canvas — once rendered, the controls bar (with disabled button) is shown.
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 30000 });
const disabledBtn = page.getByRole('button', { name: /annotieren/i }); // Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
await expect(disabledBtn).toBeVisible({ timeout: 5000 }); const annotateBtn = page.getByRole('button', { name: /annotieren/i });
await expect(disabledBtn).toBeDisabled(); await expect(annotateBtn).not.toBeVisible({ timeout: 5000 });
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' }); await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
}); });

View File

@@ -0,0 +1,21 @@
%PDF-1.4
1 0 obj
<</Type/Catalog/Pages 2 0 R>>
endobj
2 0 obj
<</Type/Pages/Kids[3 0 R]/Count 1>>
endobj
3 0 obj
<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer
<</Size 4/Root 1 0 R>>
startxref
190
%%EOF

View File

@@ -2,6 +2,7 @@
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.", "error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.", "error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
"annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.",
"error_document_not_found": "Das Dokument wurde nicht gefunden.", "error_document_not_found": "Das Dokument wurde nicht gefunden.",
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.", "error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.", "error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
@@ -241,6 +242,10 @@
"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_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_btn": "Jetzt auffüllen",
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.", "admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
"admin_system_backfill_hashes_heading": "Datei-Hashes berechnen",
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
"comp_expandable_show_more": "Mehr anzeigen", "comp_expandable_show_more": "Mehr anzeigen",
"comp_expandable_show_less": "Weniger anzeigen", "comp_expandable_show_less": "Weniger anzeigen",
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.", "error_comment_not_found": "Der Kommentar wurde nicht gefunden.",

View File

@@ -2,6 +2,7 @@
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Annotation not found.", "error_annotation_not_found": "Annotation not found.",
"error_annotation_overlap": "The annotation overlaps an existing one.", "error_annotation_overlap": "The annotation overlaps an existing one.",
"annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.",
"error_document_not_found": "Document not found.", "error_document_not_found": "Document not found.",
"error_document_no_file": "No file is associated with this document.", "error_document_no_file": "No file is associated with this document.",
"error_file_not_found": "The file could not be found in storage.", "error_file_not_found": "The file could not be found in storage.",
@@ -241,6 +242,10 @@
"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_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_btn": "Backfill now",
"admin_system_backfill_success": "{count} documents were backfilled.", "admin_system_backfill_success": "{count} documents were backfilled.",
"admin_system_backfill_hashes_heading": "Compute file hashes",
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
"admin_system_backfill_hashes_btn": "Compute file hashes",
"admin_system_backfill_hashes_success": "{count} documents were updated.",
"comp_expandable_show_more": "Show more", "comp_expandable_show_more": "Show more",
"comp_expandable_show_less": "Show less", "comp_expandable_show_less": "Show less",
"error_comment_not_found": "The comment could not be found.", "error_comment_not_found": "The comment could not be found.",

View File

@@ -2,6 +2,7 @@
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Anotación no encontrada.", "error_annotation_not_found": "Anotación no encontrada.",
"error_annotation_overlap": "La anotación se superpone con una existente.", "error_annotation_overlap": "La anotación se superpone con una existente.",
"annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.",
"error_document_not_found": "Documento no encontrado.", "error_document_not_found": "Documento no encontrado.",
"error_document_no_file": "No hay ningún archivo asociado a este documento.", "error_document_no_file": "No hay ningún archivo asociado a este documento.",
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.", "error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
@@ -241,6 +242,10 @@
"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_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_btn": "Completar ahora",
"admin_system_backfill_success": "{count} documentos fueron completados.", "admin_system_backfill_success": "{count} documentos fueron completados.",
"admin_system_backfill_hashes_heading": "Calcular hashes de archivo",
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
"comp_expandable_show_more": "Mostrar más", "comp_expandable_show_more": "Mostrar más",
"comp_expandable_show_less": "Mostrar menos", "comp_expandable_show_less": "Mostrar menos",
"error_comment_not_found": "El comentario no pudo encontrarse.", "error_comment_not_found": "El comentario no pudo encontrarse.",

View File

@@ -6,6 +6,7 @@ type Doc = {
id: string; id: string;
filePath?: string | null; filePath?: string | null;
contentType?: string | null; contentType?: string | null;
fileHash?: string | null;
}; };
type Props = { type Props = {
@@ -83,6 +84,7 @@ let {
bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage} bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={onAnnotationClick} onAnnotationClick={onAnnotationClick}
documentFileHash={doc.fileHash ?? null}
/> />
{:else if fileUrl} {:else if fileUrl}
<div class="flex h-full w-full items-center justify-center overflow-auto p-8"> <div class="flex h-full w-full items-center justify-center overflow-auto p-8">

View File

@@ -11,7 +11,8 @@ let {
annotateMode = $bindable(false), annotateMode = $bindable(false),
activeAnnotationId = $bindable<string | null>(null), activeAnnotationId = $bindable<string | null>(null),
activeAnnotationPage = $bindable<number | null>(null), activeAnnotationPage = $bindable<number | null>(null),
onAnnotationClick onAnnotationClick,
documentFileHash
}: { }: {
url: string; url: string;
documentId?: string; documentId?: string;
@@ -19,6 +20,7 @@ let {
activeAnnotationId?: string | null; activeAnnotationId?: string | null;
activeAnnotationPage?: number | null; activeAnnotationPage?: number | null;
onAnnotationClick?: (id: string) => void; onAnnotationClick?: (id: string) => void;
documentFileHash?: string | null;
} = $props(); } = $props();
let pdfDoc = $state<PDFDocumentProxy | null>(null); let pdfDoc = $state<PDFDocumentProxy | null>(null);
@@ -51,6 +53,7 @@ type Annotation = {
height: number; height: number;
color: string; color: string;
createdAt: string; createdAt: string;
fileHash?: string | null;
}; };
let annotations = $state<Annotation[]>([]); let annotations = $state<Annotation[]>([]);
@@ -58,6 +61,11 @@ let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>(); let commentCounts = new SvelteMap<string, number>();
let showAnnotations = $state(true); let showAnnotations = $state(true);
const visibleAnnotations = $derived(
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
);
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
onMount(async () => { onMount(async () => {
// Dynamic import keeps pdfjs out of the SSR bundle entirely // Dynamic import keeps pdfjs out of the SSR bundle entirely
const [lib, { default: workerUrl }] = await Promise.all([ const [lib, { default: workerUrl }] = await Promise.all([
@@ -306,6 +314,27 @@ function zoomOut() {
</div> </div>
{:else} {:else}
<div class="flex h-full w-full flex-col bg-[#2A2A2A]"> <div class="flex h-full w-full flex-col bg-[#2A2A2A]">
{#if outdatedCount > 0}
<div
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
data-testid="annotation-outdated-notice"
>
<svg
class="h-4 w-4 shrink-0 text-amber-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
</div>
{/if}
<!-- Controls --> <!-- Controls -->
<div <div
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2" class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
@@ -466,7 +495,7 @@ function zoomOut() {
></div> ></div>
{#if showAnnotations} {#if showAnnotations}
<AnnotationLayer <AnnotationLayer
annotations={annotations.filter((a) => a.pageNumber === currentPage)} annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode} canAnnotate={annotateMode}
color={annotateColor} color={annotateColor}
onDraw={handleAnnotationDraw} onDraw={handleAnnotationDraw}

View File

@@ -180,6 +180,86 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/documents/{documentId}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getDocumentComments"];
put?: never;
post: operations["postDocumentComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/comments/{commentId}/replies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["replyToDocumentComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listAnnotations"];
put?: never;
post: operations["createAnnotation"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getAnnotationComments"];
put?: never;
post: operations["postAnnotationComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["replyToAnnotationComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/reset-password": { "/api/auth/reset-password": {
parameters: { parameters: {
query?: never; query?: never;
@@ -260,6 +340,22 @@ export interface paths {
patch: operations["updateGroup"]; patch: operations["updateGroup"];
trace?: never; trace?: never;
}; };
"/api/documents/{documentId}/comments/{commentId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteComment"];
options?: never;
head?: never;
patch: operations["editComment"];
trace?: never;
};
"/api/tags": { "/api/tags": {
parameters: { parameters: {
query?: never; query?: never;
@@ -420,6 +516,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/documents/{documentId}/annotations/{annotationId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteAnnotation"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -510,6 +622,7 @@ export interface components {
title: string; title: string;
filePath?: string; filePath?: string;
contentType?: string; contentType?: string;
fileHash?: string;
originalFilename: string; originalFilename: string;
/** @enum {string} */ /** @enum {string} */
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
@@ -548,6 +661,63 @@ export interface components {
name?: string; name?: string;
permissions?: string[]; permissions?: string[];
}; };
CreateCommentDTO: {
content?: string;
};
DocumentComment: {
/** Format: uuid */
id: string;
/** Format: uuid */
documentId: string;
/** Format: uuid */
annotationId?: string;
/** Format: uuid */
parentId?: string;
/** Format: uuid */
authorId?: string;
authorName: string;
content: string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
replies: components["schemas"]["DocumentComment"][];
};
CreateAnnotationDTO: {
/** Format: int32 */
pageNumber?: number;
/** Format: double */
x?: number;
/** Format: double */
y?: number;
/** Format: double */
width?: number;
/** Format: double */
height?: number;
color?: string;
};
DocumentAnnotation: {
/** Format: uuid */
id: string;
/** Format: uuid */
documentId: string;
/** Format: int32 */
pageNumber: number;
/** Format: double */
x: number;
/** Format: double */
y: number;
/** Format: double */
width: number;
/** Format: double */
height: number;
color: string;
fileHash?: string;
/** Format: uuid */
createdBy?: string;
/** Format: date-time */
createdAt: string;
};
ResetPasswordRequest: { ResetPasswordRequest: {
token?: string; token?: string;
newPassword?: string; newPassword?: string;
@@ -1062,6 +1232,205 @@ export interface operations {
}; };
}; };
}; };
getDocumentComments: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"][];
};
};
};
};
postDocumentComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
replyToDocumentComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
listAnnotations: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentAnnotation"][];
};
};
};
};
createAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateAnnotationDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentAnnotation"];
};
};
};
};
getAnnotationComments: {
parameters: {
query?: never;
header?: never;
path: {
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"][];
};
};
};
};
postAnnotationComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
replyToAnnotationComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
resetPassword: { resetPassword: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1192,6 +1561,54 @@ export interface operations {
}; };
}; };
}; };
deleteComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
editComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
searchTags: { searchTags: {
parameters: { parameters: {
query?: { query?: {
@@ -1422,4 +1839,25 @@ export interface operations {
}; };
}; };
}; };
deleteAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
} }

View File

@@ -250,16 +250,6 @@ $effect(() => {
> >
{doc.title || doc.originalFilename} {doc.title || doc.originalFilename}
</h3> </h3>
<!-- Status Badge -->
<span
class="ml-3 inline-flex items-center rounded-full border px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase
{doc.status === 'UPLOADED'
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
: 'border-yellow-200 bg-yellow-50 text-yellow-700'}"
>
{doc.status}
</span>
</div> </div>
<!-- Metadata Row --> <!-- Metadata Row -->

View File

@@ -11,6 +11,8 @@ let editingTagName = $state('');
let editingGroupId: string | null = $state(null); let editingGroupId: string | null = $state(null);
let backfillResult: number | null = $state(null); let backfillResult: number | null = $state(null);
let backfillLoading = $state(false); let backfillLoading = $state(false);
let backfillHashesResult: number | null = $state(null);
let backfillHashesLoading = $state(false);
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION']; const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
@@ -45,6 +47,20 @@ async function backfillVersions() {
backfillLoading = false; backfillLoading = false;
} }
} }
async function backfillFileHashes() {
backfillHashesLoading = true;
backfillHashesResult = null;
try {
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
if (res.ok) {
const data = await res.json();
backfillHashesResult = data.count;
}
} finally {
backfillHashesLoading = false;
}
}
</script> </script>
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
@@ -535,5 +551,24 @@ async function backfillVersions() {
</p> </p>
{/if} {/if}
</div> </div>
<div class="mt-4 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_hashes_heading()}
</h2>
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_hashes_description()}</p>
<button
onclick={backfillFileHashes}
disabled={backfillHashesLoading}
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"
>
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
</button>
{#if backfillHashesResult !== null}
<p class="mt-4 text-sm font-medium text-brand-navy">
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
</p>
{/if}
</div>
{/if} {/if}
</div> </div>