feat: add PDF annotation feature (#40)

Backend:
- Add ANNOTATE_ALL permission
- Add ANNOTATION_NOT_FOUND and ANNOTATION_OVERLAP error codes
- V10 migration: document_annotations table with page/rect/color/owner
- DocumentAnnotation entity, AnnotationRepository, CreateAnnotationDTO
- AnnotationService: overlap detection (rectangle intersection), ownership enforcement on delete
- AnnotationController: GET (authenticated), POST/DELETE (ANNOTATE_ALL)
- 15 new tests (AnnotationServiceTest, AnnotationControllerTest) — TDD red/green

Frontend:
- AnnotationLayer.svelte: pointer-event drawing, colored rect overlays, delete buttons
- PdfViewer.svelte: annotate toggle, color picker, loads/saves/deletes annotations via API
- Disabled annotate button with tooltip for users without ANNOTATE_ALL
- canAnnotate exposed from layout server, passed to PdfViewer
- errors.ts + de/en/es translations for new error codes
- 3 new unit tests for AnnotationLayer — TDD red/green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-23 23:27:21 +01:00
parent ca5726e7c3
commit b45ec744b2
27 changed files with 903 additions and 9 deletions

View File

@@ -1,11 +1,10 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
const groups: { permissions: string[] }[] = locals.user?.groups ?? [];
return {
user: locals.user,
canWrite:
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
canAnnotate: groups.some((g) => g.permissions.includes('ANNOTATE_ALL'))
};
};

View File

@@ -29,6 +29,7 @@ const makeUser = (overrides = {}) => ({
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
users: [makeUser()],
groups: [makeGroup()],
tags: []

View File

@@ -24,7 +24,13 @@ const makeUser = (overrides = {}) => ({
...overrides
});
const baseData = { user: undefined, canWrite: true, editUser: makeUser(), groups };
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
editUser: makeUser(),
groups
};
afterEach(cleanup);

View File

@@ -10,7 +10,7 @@ const groups = [
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
];
const baseData = { user: undefined, canWrite: true, groups };
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
afterEach(cleanup);

View File

@@ -12,6 +12,7 @@ afterEach(cleanup);
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }

View File

@@ -875,7 +875,7 @@ function versionLabel(v: VersionSummary, index: number): string {
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
</div>
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
<PdfViewer url={fileUrl} />
<PdfViewer url={fileUrl} documentId={doc.id} canAnnotate={data.canAnnotate} />
{:else if fileUrl}
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
<img

View File

@@ -10,6 +10,7 @@ afterEach(cleanup);
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
persons: [],
initialSenderId: '',
initialSenderName: '',

View File

@@ -22,6 +22,7 @@ const makeData = (overrides = {}) => ({
createdAt: ''
},
canWrite: true,
canAnnotate: false,
...overrides
});

View File

@@ -20,6 +20,7 @@ afterEach(cleanup);
const emptyData = {
user: undefined,
canWrite: true,
canAnnotate: false,
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
documents: [],
initialValues: { senderName: '', receiverName: '' },

View File

@@ -14,7 +14,7 @@ const makePerson = (overrides = {}) => ({
...overrides
});
const emptyData = { user: undefined, canWrite: true, q: '', persons: [] };
const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] };
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
afterEach(cleanup);