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}