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

@@ -216,3 +216,35 @@ test.describe('Admin — tag management', () => {
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) ─────────────────────────────────────────
test.describe('PDF annotations — read-only user', () => {
// Isolated session — does not share the admin storage state
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);
await page.goto('/login');
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';
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
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 });
await expect(disabledBtn).toBeVisible({ timeout: 5000 });
await expect(disabledBtn).toBeDisabled();
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
const annotateBtn = page.getByRole('button', { name: /annotieren/i });
await expect(annotateBtn).not.toBeVisible({ timeout: 5000 });
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",
"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.",
@@ -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_btn": "Jetzt auffüllen",
"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_less": "Weniger anzeigen",
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",

View File

@@ -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.",
@@ -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_btn": "Backfill now",
"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_less": "Show less",
"error_comment_not_found": "The comment could not be found.",

View File

@@ -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.",
@@ -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_btn": "Completar ahora",
"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_less": "Mostrar menos",
"error_comment_not_found": "El comentario no pudo encontrarse.",

View File

@@ -6,6 +6,7 @@ type Doc = {
id: string;
filePath?: string | null;
contentType?: string | null;
fileHash?: string | null;
};
type Props = {
@@ -83,6 +84,7 @@ let {
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={onAnnotationClick}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}
<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),
activeAnnotationId = $bindable<string | null>(null),
activeAnnotationPage = $bindable<number | null>(null),
onAnnotationClick
onAnnotationClick,
documentFileHash
}: {
url: string;
documentId?: string;
@@ -19,6 +20,7 @@ let {
activeAnnotationId?: string | null;
activeAnnotationPage?: number | null;
onAnnotationClick?: (id: string) => void;
documentFileHash?: string | null;
} = $props();
let pdfDoc = $state<PDFDocumentProxy | null>(null);
@@ -51,6 +53,7 @@ type Annotation = {
height: number;
color: string;
createdAt: string;
fileHash?: string | null;
};
let annotations = $state<Annotation[]>([]);
@@ -58,6 +61,11 @@ let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>();
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 () => {
// Dynamic import keeps pdfjs out of the SSR bundle entirely
const [lib, { default: workerUrl }] = await Promise.all([
@@ -306,6 +314,27 @@ function zoomOut() {
</div>
{:else}
<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 -->
<div
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>
{#if showAnnotations}
<AnnotationLayer
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode}
color={annotateColor}
onDraw={handleAnnotationDraw}

View File

@@ -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<string, never>;
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;
};
};
};
}

View File

@@ -250,16 +250,6 @@ $effect(() => {
>
{doc.title || doc.originalFilename}
</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>
<!-- Metadata Row -->

View File

@@ -11,6 +11,8 @@ let editingTagName = $state('');
let editingGroupId: string | null = $state(null);
let backfillResult: number | null = $state(null);
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'];
@@ -45,6 +47,20 @@ async function backfillVersions() {
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>
<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>
{/if}
</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}
</div>