diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java index 04ca3c77..ce0e0f29 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java @@ -4,10 +4,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.AnnotationService; +import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.UserService; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; @@ -23,6 +25,7 @@ import java.util.UUID; public class AnnotationController { private final AnnotationService annotationService; + private final DocumentService documentService; private final UserService userService; @GetMapping @@ -38,7 +41,8 @@ public class AnnotationController { @RequestBody CreateAnnotationDTO dto, Authentication 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}") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index 7c0abb37..5fa21c57 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -39,6 +39,10 @@ public class Document { @Column(name = "content_type") 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") @Column(name = "original_filename", nullable = false) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java index cdf6a079..281f88a2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java @@ -49,6 +49,9 @@ public class DocumentAnnotation { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private String color; + @Column(name = "file_hash", length = 64) + private String fileHash; + @Column(name = "created_by") private UUID createdBy; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java index 6f1f91d1..f6f0687a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java @@ -23,7 +23,7 @@ public class AnnotationService { } @Transactional - public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) { + public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) { List existing = annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber()); @@ -41,6 +41,7 @@ public class AnnotationService { .width(dto.getWidth()) .height(dto.getHeight()) .color(dto.getColor()) + .fileHash(fileHash) .createdBy(userId) .build(); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 521b8199..919211df 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -64,10 +64,11 @@ public class DocumentService { } // 2. Delegate Storage to FileService - String s3Key = fileService.uploadFile(file, originalFilename); + FileService.UploadResult upload = fileService.uploadFile(file, originalFilename); // 3. Update Database - document.setFilePath(s3Key); + document.setFilePath(upload.s3Key()); + document.setFileHash(upload.fileHash()); document.setContentType(file.getContentType()); if (document.getStatus() == DocumentStatus.PLACEHOLDER) { document.setStatus(DocumentStatus.UPLOADED); @@ -120,8 +121,9 @@ public class DocumentService { // Datei if (file != null && !file.isEmpty()) { - String s3Key = fileService.uploadFile(file, file.getOriginalFilename()); - doc.setFilePath(s3Key); + FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename()); + doc.setFilePath(upload.s3Key()); + doc.setFileHash(upload.fileHash()); doc.setContentType(file.getContentType()); doc.setStatus(DocumentStatus.UPLOADED); } @@ -170,12 +172,9 @@ public class DocumentService { // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde) if (newFile != null && !newFile.isEmpty()) { - // Alte Datei könnte man hier theoretisch löschen (optional) - - // Neue Datei hochladen - String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename()); - - doc.setFilePath(s3Key); + FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename()); + doc.setFilePath(upload.s3Key()); + doc.setFileHash(upload.fileHash()); doc.setOriginalFilename(newFile.getOriginalFilename()); doc.setContentType(newFile.getContentType()); doc.setStatus(DocumentStatus.UPLOADED); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java index 30f421b4..f2142fdc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java @@ -13,6 +13,8 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.core.io.InputStreamResource; import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.UUID; @Service @@ -29,10 +31,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 { - // Generate secure unique path: "documents/UUID_filename" + public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException { + byte[] bytes = file.getBytes(); + String fileHash = sha256Hex(bytes); String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename; try { @@ -42,11 +48,10 @@ public class FileService { .contentType(file.getContentType()) .build(); - s3Client.putObject(putObjectRequest, - RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes)); - log.info("Uploaded file to S3: {}", s3Key); - return s3Key; + log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash); + return new UploadResult(s3Key, fileHash); } catch (S3Exception e) { log.error("S3 Upload Error", e); throw new IOException("Failed to upload file to storage", e); @@ -58,32 +63,52 @@ public class FileService { * Returns a wrapper containing the stream and content type. */ public S3FileDownload downloadFile(String s3Key) { - try { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build(); + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); - ResponseInputStream s3Object = s3Client.getObject(getObjectRequest); + ResponseInputStream s3Object = s3Client.getObject(getObjectRequest); - // Use whatever content type S3 has stored (set at upload time) - String contentType = s3Object.response().contentType(); - if (contentType == null || contentType.isBlank()) { - contentType = "application/octet-stream"; + String contentType = s3Object.response().contentType(); + if (contentType == null || contentType.isBlank()) { + 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 + + // ─── 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) {} - // Custom Exception public static class StorageFileNotFoundException extends RuntimeException { public StorageFileNotFoundException(String message) { super(message); } } diff --git a/backend/src/main/resources/db/migration/V13__add_file_hash.sql b/backend/src/main/resources/db/migration/V13__add_file_hash.sql new file mode 100644 index 00000000..f03e1b7a --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__add_file_hash.sql @@ -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); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java index a2a4d3ec..686ccf3e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; @@ -36,6 +38,7 @@ class AnnotationControllerTest { @Autowired MockMvc mockMvc; @MockitoBean AnnotationService annotationService; + @MockitoBean DocumentService documentService; @MockitoBean UserService userService; @MockitoBean CustomUserDetailsService customUserDetailsService; @@ -85,7 +88,8 @@ class AnnotationControllerTest { DocumentAnnotation saved = DocumentAnnotation.builder() .id(UUID.randomUUID()).documentId(docId).pageNumber(1) .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") .contentType(MediaType.APPLICATION_JSON) @@ -97,7 +101,8 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "ANNOTATE_ALL") 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")); mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java index 319c4b8b..33019c9d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -44,7 +44,7 @@ class AnnotationServiceTest { when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)) .thenReturn(List.of(existing)); - assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId)) + assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null)) .isInstanceOf(DomainException.class) .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(); 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); verify(annotationRepository).save(any()); @@ -117,6 +117,35 @@ class AnnotationServiceTest { 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 ────────────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index e531bc44..1c8c413b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -135,6 +135,48 @@ class DocumentServiceTest { 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 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 ─────────────────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java new file mode 100644 index 00000000..d8f719eb --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java @@ -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()); + } +} diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts index 6c17518e..8aae84bf 100644 --- a/frontend/e2e/documents.spec.ts +++ b/frontend/e2e/documents.spec.ts @@ -347,6 +347,135 @@ 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) ───────────────────────────────────────── test.describe('PDF annotations — read-only user', () => { diff --git a/frontend/e2e/fixtures/minimal2.pdf b/frontend/e2e/fixtures/minimal2.pdf new file mode 100644 index 00000000..45e6dcfb --- /dev/null +++ b/frontend/e2e/fixtures/minimal2.pdf @@ -0,0 +1,21 @@ +%PDF-1.4 +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<> +endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer +<> +startxref +190 +%%EOF \ No newline at end of file diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8d8a86c9..bc1aa5b2 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -2,6 +2,7 @@ "$schema": "https://inlang.com/schema/inlang-message-format", "error_annotation_not_found": "Die Annotation wurde nicht gefunden.", "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_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.", "error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8b9fbdf5..6ca3148b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2,6 +2,7 @@ "$schema": "https://inlang.com/schema/inlang-message-format", "error_annotation_not_found": "Annotation not found.", "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_no_file": "No file is associated with this document.", "error_file_not_found": "The file could not be found in storage.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 01d37915..4d8bf77a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -2,6 +2,7 @@ "$schema": "https://inlang.com/schema/inlang-message-format", "error_annotation_not_found": "Anotación no encontrada.", "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_no_file": "No hay ningún archivo asociado a este documento.", "error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.", diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 1a9ff68a..9d1a3381 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -4,6 +4,7 @@ import { SvelteMap } from 'svelte/reactivity'; import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist'; import AnnotationLayer from './AnnotationLayer.svelte'; import AnnotationCommentPanel from './AnnotationCommentPanel.svelte'; +import { m } from '$lib/paraglide/messages.js'; let { url, @@ -11,7 +12,8 @@ let { canAnnotate = false, canComment, currentUserId, - canAdmin + canAdmin, + documentFileHash }: { url: string; documentId?: string; @@ -19,6 +21,7 @@ let { canComment?: boolean; currentUserId?: string | null; canAdmin?: boolean; + documentFileHash?: string | null; } = $props(); let pdfDoc = $state(null); @@ -51,6 +54,7 @@ type Annotation = { height: number; color: string; createdAt: string; + fileHash?: string | null; }; let annotations = $state([]); @@ -59,6 +63,11 @@ let annotateColor = $state('#ffff00'); let commentCounts = new SvelteMap(); let activeAnnotationId = $state(null); +const visibleAnnotations = $derived( + annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash) +); +const outdatedCount = $derived(annotations.length - visibleAnnotations.length); + onMount(async () => { // Dynamic import keeps pdfjs out of the SSR bundle entirely const [lib, { default: workerUrl }] = await Promise.all([ @@ -298,6 +307,27 @@ function zoomOut() { {:else}
+ {#if outdatedCount > 0} +
+ + + + {m.annotation_outdated_notice()} +
+ {/if}
a.pageNumber === currentPage)} + annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)} canAnnotate={annotateMode} color={annotateColor} onDraw={handleAnnotationDraw} diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index f756e81f..9659f8a1 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -180,6 +180,86 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -260,6 +340,22 @@ export interface paths { patch: operations["updateGroup"]; 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": { parameters: { query?: never; @@ -420,6 +516,22 @@ export interface paths { patch?: 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; export interface components { @@ -510,6 +622,7 @@ export interface components { title: string; filePath?: string; contentType?: string; + fileHash?: string; originalFilename: string; /** @enum {string} */ status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; @@ -548,6 +661,63 @@ export interface components { name?: 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: { token?: 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: { parameters: { 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: { parameters: { 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; + }; + }; + }; } diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 2ec4747c..5b2aa6fa 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -908,6 +908,7 @@ function versionLabel(v: VersionSummary, index: number): string { canComment={canComment} currentUserId={currentUserId} canAdmin={canAdmin} + documentFileHash={doc.fileHash ?? null} /> {:else if fileUrl}