Compare commits

...

63 Commits

Author SHA1 Message Date
Marcel
63013cc86a test(e2e): update reader annotation test to match post-#61 behaviour
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m22s
CI / Backend Unit Tests (pull_request) Successful in 2m23s
CI / Backend Unit Tests (push) Successful in 2m16s
CI / Unit & Component Tests (push) Successful in 2m29s
CI / E2E Tests (pull_request) Successful in 23m27s
CI / E2E Tests (push) Failing after 16m19s
The old test waited for the PDF canvas (30 s timeout) before checking
for a disabled Annotieren button — a brittle dependency that caused
consistent failure because the reader's file fetch never completed in
CI. Since issue #61 will remove the disabled button entirely for users
without ANNOTATE_ALL, rewrite the test to assert the button is absent,
which is correct both in the interim and after #61.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 18:18:36 +01:00
Marcel
9e2419a48e feat(frontend): remove document status pills
Status badges (UPLOADED, PLACEHOLDER, etc.) provided no real value
to users and have been removed from the document list and document
detail header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:55:53 +01:00
Marcel
00195dc8db feat(frontend): add backfill file hashes card to admin System tab
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m28s
CI / Backend Unit Tests (push) Successful in 2m17s
CI / E2E Tests (push) Failing after 24m34s
CI / Unit & Component Tests (pull_request) Successful in 2m16s
CI / Backend Unit Tests (pull_request) Successful in 2m7s
CI / E2E Tests (pull_request) Failing after 22m53s
- System tab gains a second card with a 'Datei-Hashes berechnen' button
  that calls POST /api/admin/backfill-file-hashes and shows the updated count
- i18n: admin_system_backfill_hashes_* keys added in de/en/es
- E2E: test verifies the button triggers the backfill and shows the success message

Closes #56

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:33:01 +01:00
Marcel
0ec86220d3 feat(backend): add POST /api/admin/backfill-file-hashes endpoint
- DocumentRepository: findByFileHashIsNullAndFilePathIsNotNull()
- AnnotationRepository: findByDocumentIdAndFileHashIsNull()
- FileService: downloadFileBytes() downloads raw bytes from S3 for hashing
- AnnotationService: backfillAnnotationFileHashForDocument() sets hash on null-hash annotations
- DocumentService: backfillFileHashes() iterates documents with null hash,
  downloads bytes, computes SHA-256, saves doc, then propagates hash to annotations
- AdminController: POST /api/admin/backfill-file-hashes delegates to DocumentService

Closes #56

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:32:29 +01:00
Marcel
7fbc33b32d feat(frontend): hide outdated annotations when file version changes
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- Regenerate API types with fileHash on Document and DocumentAnnotation
- PdfViewer accepts documentFileHash prop; filters visibleAnnotations to
  those whose hash matches (or is null) and shows an amber notice banner
  when any annotations are hidden due to a hash mismatch
- Document detail page passes doc.fileHash to PdfViewer
- Add i18n key annotation_outdated_notice in de/en/es
- E2E: two new tests covering hide-on-reupload and restore-on-original-reupload
  scenarios; add minimal2.pdf fixture for a different-hash upload

Closes #55

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:09:26 +01:00
Marcel
93f57477cd feat(backend): hash uploaded files and store hash on documents and annotations
- Flyway V13: add file_hash column to documents and document_annotations
- FileService.uploadFile() now returns UploadResult(s3Key, fileHash) with SHA-256 hash computed from raw bytes
- Document and DocumentAnnotation models gain a fileHash field
- DocumentService propagates the hash at all three upload sites (storeDocument, createDocument, updateDocument)
- AnnotationService.createAnnotation() accepts and persists a fileHash
- AnnotationController resolves the document's hash and passes it through

Closes #55

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:08:55 +01:00
Marcel
34c66f80fc fix(e2e): fix annotation delete test and harden comments fetch
Some checks failed
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 2m30s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / E2E Tests (push) Successful in 22m47s
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
- Add aria-label="Kommentare anzeigen" to annotation container div so
  getByRole('button', { name: /annotation löschen/i }) no longer
  matches the container (its name was previously inherited from the
  child delete button, causing the test to click the wrong element)
- Wrap the server-side comments fetch in a .catch and try/catch so a
  network error or non-JSON response never crashes the document load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:27:15 +01:00
Marcel
fd03e56c85 fix(comments): remount AnnotationCommentPanel when switching annotations
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m13s
CI / Backend Unit Tests (push) Successful in 2m23s
CI / E2E Tests (push) Failing after 24m41s
CI / Unit & Component Tests (pull_request) Failing after 2m8s
CI / Backend Unit Tests (pull_request) Successful in 2m8s
CI / E2E Tests (pull_request) Has been cancelled
Wrap the panel in {#key activeAnnotationId} so Svelte destroys and
recreates it on every annotation change, triggering onMount and
loading the correct comments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:28:44 +01:00
Marcel
af57b4e530 feat(annotations): add hover effect — increased opacity and inset border on hover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:26:25 +01:00
Marcel
aaa9286612 feat(comments): warn before deleting annotation with comments
Show a native confirm() dialog when the annotation has ≥1 comment,
listing the count so the user knows what will be lost.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:20:55 +01:00
Marcel
646674b06a fix(comments): open panel on annotation creation and enlarge comment count pill
- Auto-open AnnotationCommentPanel immediately after drawing a new annotation
- Move comment count pill to bottom-right corner (was centered at bottom)
- Increase pill size: font 11px bold, padding 2px 6px, min-width 20px, drop shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:11:36 +01:00
Marcel
1070e6e9ec feat(comments): add CommentThread, annotation panel, Diskussion section, and i18n keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:02:38 +01:00
Marcel
3e5d296b09 feat(comments): add CommentController and CreateCommentDTO (green)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:36:33 +01:00
Marcel
ee49bac2ef test(comments): add failing CommentControllerTest (red)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:34:47 +01:00
Marcel
48040dc7e4 feat(comments): add DocumentComment entity, CommentRepository, and CommentService (green)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:33:39 +01:00
Marcel
83e5a1fde5 test(comments): add failing CommentServiceTest and V12 migration (red)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:32:11 +01:00
Marcel
37f5c3d005 feat(db): add migration to grant ANNOTATE_ALL to existing admin groups
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 2m27s
CI / Backend Unit Tests (pull_request) Successful in 2m12s
CI / E2E Tests (pull_request) Successful in 23m43s
CI / Unit & Component Tests (push) Successful in 2m28s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / E2E Tests (push) Successful in 22m17s
Covers existing deployments where the Administrators group was created
before DataInitializer started including ANNOTATE_ALL.

Refs #40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:52:32 +01:00
Marcel
eb8bcdb426 fix(frontend): make annotation delete button fully opaque
Some checks failed
CI / Backend Unit Tests (pull_request) Successful in 2m15s
CI / E2E Tests (pull_request) Successful in 22m58s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m29s
Replace opacity: 0.3 on the annotation container with an rgba
background so child elements (the × button) are not affected by
the parent's opacity and render at full opacity.

Refs #40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:49:52 +01:00
Marcel
05f3ce687f test(e2e): rewrite PDF viewer and annotation beforeAll to use API calls
Some checks failed
CI / E2E Tests (pull_request) Waiting to run
CI / Unit & Component Tests (push) Successful in 2m27s
CI / Backend Unit Tests (push) Successful in 2m17s
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (push) Has started running
- Replace UI-based document setup in beforeAll hooks with direct API
  calls via Playwright's request fixture — avoids the 90s timeout from
  navigating + uploading through the Docker dev server
- Fix non-PDF test: create a file-less document in beforeAll instead of
  relying on seed data that may not exist
- Share annotationDocId across describe blocks so the read-only user
  test can navigate to a known PDF document
- Add annotation visibility check before enabling annotate mode in the
  delete test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:26:59 +01:00
Marcel
06e846f2f8 fix(frontend): use closest() to skip pointer capture on annotation children
When a child element inside an annotation div (e.g. the delete button)
was clicked, the AnnotationLayer's pointerdown handler would call
setPointerCapture, preventing the child's click event from firing.
Using closest('[data-annotation]') instead of checking dataset.annotation
on the target directly fixes delete buttons inside annotation elements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:26:26 +01:00
Marcel
ea1c097ae0 fix(e2e): activate e2e profile in dev mode and create reader user idempotently
- Add e2e to the dev Maven profile's spring.profiles.active so
  DataInitializer always runs when developing/testing locally
- Create the reader test user independently of the person-seed guard
  so it survives restarts where seed data already exists
- Set SPRING_PROFILES_ACTIVE=dev,e2e in docker-compose backend service

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:25:54 +01:00
Marcel
b45ec744b2 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>
2026-03-23 23:27:21 +01:00
Marcel
ca5726e7c3 fix(frontend): zoom buttons now re-render immediately (#39)
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 4m42s
CI / Backend Unit Tests (pull_request) Successful in 2m18s
CI / E2E Tests (pull_request) Failing after 13m15s
CI / Unit & Component Tests (push) Successful in 2m20s
CI / Backend Unit Tests (push) Successful in 2m11s
CI / E2E Tests (push) Failing after 13m25s
scale was only read inside the async renderPage function, so Svelte 5
never tracked it as a reactive dependency of the effect. Reading scale
synchronously in the effect condition registers it as a dependency and
triggers a re-render on every zoom change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:45:25 +01:00
Marcel
0ef81e20f6 devops: add rebuild-frontend.sh script
Some checks are pending
CI / Unit & Component Tests (push) Successful in 2m36s
CI / Backend Unit Tests (push) Successful in 2m3s
CI / E2E Tests (push) Has started running
Stops the container, removes the stale node_modules volume, and
rebuilds the image. Run this after adding or updating npm dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:39:53 +01:00
Marcel
1ad8fffd1b fix(frontend): load pdfjs-dist dynamically to avoid SSR crash (#39)
Static import of pdfjs-dist fails during SSR because DOMMatrix and
other browser globals are unavailable in Node.js. Move the import into
onMount so it only ever executes in the browser. A plain pdfjsLib
variable holds the module; a $state boolean pdfjsReady triggers the
load-document effect once the library is available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:39:40 +01:00
Marcel
5fb6a1eec0 feat(frontend): replace iframe with PDF.js viewer (#39)
- Install pdfjs-dist v5 and add optimizeDeps pre-bundle config
- New PdfViewer.svelte component: renders each page on a <canvas> with
  correct device-pixel-ratio scaling, overlays a text layer (enables
  text selection; foundation for annotations in #40), prev/next
  navigation, zoom controls, and lazy page rendering (only current ±1
  pre-fetched — avoids freezing on multi-page documents)
- Replace the <iframe> in documents/[id]/+page.svelte with PdfViewer;
  image attachments continue to use <img>; detection now uses
  doc.contentType instead of filename extension
- Unit tests for navigation controls and page counter (pdfjs mocked)
- E2E tests: PDF renders as canvas (not iframe), nav controls visible,
  image fallback stays as <img>; minimal.pdf fixture for upload tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:10:02 +01:00
Marcel
4f69457a68 fix(dev): inject Authorization header from cookie in Vite dev proxy
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m13s
CI / Backend Unit Tests (pull_request) Successful in 2m9s
CI / E2E Tests (pull_request) Successful in 20m15s
CI / Unit & Component Tests (push) Successful in 2m4s
CI / Backend Unit Tests (push) Successful in 2m8s
CI / E2E Tests (push) Failing after 23s
Browser-side fetch('/api/...') calls bypass SvelteKit's handleFetch hook
(which adds the Authorization header from the auth_token cookie for SSR).
As a result, client-side API calls in the dev server always got a 401.

Add a proxy configure hook that extracts the auth_token cookie from incoming
requests and sets it as the Authorization header before forwarding to the
backend. This makes browser-side fetches (history panel, file preview, etc.)
work correctly in dev mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:05:20 +01:00
Marcel
62f62a89a1 fix(e2e): wait for hydration on document detail page in history tests
Some checks failed
CI / Backend Unit Tests (pull_request) Waiting to run
CI / E2E Tests (pull_request) Waiting to run
CI / Unit & Component Tests (push) Successful in 2m11s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / E2E Tests (push) Failing after 28m46s
All three history tests navigated to the doc page but didn't wait for
SvelteKit hydration, so the toggle onclick wasn't registered yet. Also
wait for versions to load (API call) before asserting on version items
or the compare button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:03:37 +01:00
Marcel
d84b997965 fix(frontend): show version numbers oldest-first (1 = oldest)
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 13:05:31 +01:00
Marcel
8c86beb9f9 feat(frontend): add expandable text component for long fields
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Adds ExpandableText.svelte which clamps text to 10 lines and shows a
toggle button only when the content actually overflows. Applied to the
summary and transcription fields on the document detail page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:53:04 +01:00
Marcel
0020d1e773 fix(frontend): improve PDF zoom and diff readability
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- PDF viewer: append #zoom=page-width to iframe src so A4 letters fill
  the panel width instead of leaving large grey gutters
- Diff view: trim unchanged context to 4 words either side of each
  change, replacing long runs with '…' so edits are easy to spot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:46:56 +01:00
Marcel
47b8cc9340 feat(frontend): add System tab to admin panel with backfill-versions action
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m13s
CI / Backend Unit Tests (pull_request) Successful in 2m17s
CI / E2E Tests (pull_request) Failing after 22m45s
Admin can trigger an initial history snapshot for all documents without
version history. Shows count of backfilled documents after completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:33:39 +01:00
Marcel
3e65b2feb3 feat: add admin backfill-versions endpoint for documents without history
Adds POST /api/admin/backfill-versions which creates an initial snapshot
(editorName="Datenimport", changedFields=[]) for every document that has
no version entry yet, using the document's createdAt as the version timestamp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:27:21 +01:00
Marcel
f32ed32f67 fix(frontend): correct diff direction in history panel
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m9s
CI / Backend Unit Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Failing after 22m54s
versions array is ascending (oldest first), so the previous version
is at idx-1, not idx+1. Using idx+1 caused added/removed to be swapped,
showing new text as red and old text as green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:13:11 +01:00
Marcel
4a0d3b3bea test(e2e): add history panel playwright spec
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m19s
CI / Backend Unit Tests (push) Successful in 2m11s
CI / E2E Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Successful in 2m7s
CI / Backend Unit Tests (pull_request) Successful in 2m0s
CI / E2E Tests (pull_request) Failing after 21m58s
Three scenarios: versions list appears after edits, diff shows changed
field, compare mode displays diff between two selected versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:59:43 +01:00
Marcel
d4b1a709d7 feat(frontend): add document history panel with diff and compare mode
Adds a collapsible history section to the document detail view, showing
all saved versions with changed-field labels, word-level diff between
adjacent versions, and a compare mode for any two arbitrary versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:57:33 +01:00
Marcel
7af49daf9c fix: use tools.jackson (Jackson 3) instead of com.fasterxml.jackson in DocumentVersionService
Spring Boot 4 auto-configures a tools.jackson.databind.ObjectMapper bean.
The service was importing the Jackson 2 package, causing a no-qualifying-bean
error at startup.

Refs #38
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:41:16 +01:00
Marcel
28256dbd08 feat: wire document versioning into DocumentService and DocumentController
DocumentService now calls documentVersionService.recordVersion() after
createDocument and updateDocument. DocumentController exposes two new
read-only endpoints: GET /{id}/versions and GET /{id}/versions/{versionId}.

Refs #38
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:30:05 +01:00
Marcel
315b368f88 feat: add DocumentVersion entity, repository, service, and migration
Creates the document_versions table (V9) with JSONB snapshot and
changed_fields columns. DocumentVersionService records a version on
every create/update, resolves the editor name from the security context,
and computes changedFields by diffing against the previous snapshot.

Refs #38
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:29:41 +01:00
Marcel
43defa41c4 fix(e2e): wait for hydration before clicking nav dropdown in logout test
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 2m8s
CI / Backend Unit Tests (push) Successful in 2m10s
CI / E2E Tests (push) Successful in 20m18s
waitForURL('/') resolves as soon as the URL changes but before SvelteKit
finishes hydrating — the avatar button's onclick is not yet registered,
so the click has no effect and the dropdown never opens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:48:05 +01:00
Marcel
17db73d900 fix(frontend): hide nav header on forgot-password and reset-password routes
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:28:03 +01:00
Marcel
88e3fb32b3 docs: add mail configuration guide
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Covers dev (Mailpit), production SMTP, all env vars with defaults,
common provider settings, and how to disable mail entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:20:43 +01:00
Marcel
c18cdbfac1 feat(dev): add Mailpit mail catcher to docker-compose
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m6s
CI / Backend Unit Tests (pull_request) Successful in 2m7s
CI / E2E Tests (pull_request) Has been cancelled
Adds a Mailpit container that catches all outgoing emails locally so
password reset links can be tested without a real SMTP server.

- Backend defaults to MAIL_HOST=mailpit / MAIL_PORT=1025 in compose
- SMTP auth and STARTTLS disabled for Mailpit (no credentials needed)
- Web inbox available at http://localhost:8025
- Production SMTP still works by overriding MAIL_HOST, MAIL_PORT,
  MAIL_USERNAME, MAIL_SMTP_AUTH, and MAIL_STARTTLS_ENABLE in .env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:10:17 +01:00
Marcel
b9aff799fa fix(e2e): use username check instead of count() for admin user creation
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m3s
CI / Backend Unit Tests (push) Successful in 2m5s
CI / E2E Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Successful in 2m5s
CI / Backend Unit Tests (pull_request) Successful in 1m56s
CI / E2E Tests (pull_request) Failing after 18m40s
When the e2e profile is active, initE2EData (which creates a reader user)
can run before initAdminUser. The old count() == 0 guard then skips admin
creation entirely, causing every login test to fail with 401.

Switch to findByUsername(adminUsername).isEmpty() so the admin is created
regardless of which CommandLineRunner runs first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 08:46:13 +01:00
Marcel
908221f04d feat(frontend): add forgot-password and reset-password pages
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m7s
CI / Backend Unit Tests (push) Successful in 2m3s
CI / E2E Tests (push) Failing after 14m54s
CI / Unit & Component Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
- /forgot-password: email form → sends POST /api/auth/forgot-password → success banner
- /reset-password: password form reads token from URL → sends POST /api/auth/reset-password
- Login page: add "Passwort vergessen?" link
- hooks.server.ts: add /forgot-password and /reset-password to PUBLIC_PATHS; skip auth
  injection for public auth API endpoints
- errors.ts: add INVALID_RESET_TOKEN error code
- i18n: add all new message keys in de/en/es
- playwright.config.ts: use E2E_BASE_URL for webServer check URL (allows reusing docker
  dev server at port 5173 locally)
- ci.yml: pass E2E_BACKEND_URL=http://localhost:8080 to E2E test step
- e2e/password-reset.spec.ts: 5 tests (4 pass locally, full flow requires e2e profile in CI)
- Regenerated OpenAPI types including new /api/auth/* endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 07:26:35 +01:00
Marcel
5f49a5787c feat(backend): add password reset via email
- Add PasswordResetToken entity, repository (Flyway V8 migration)
- PasswordResetService: token generation, validation, nightly cleanup
- AuthController: POST /api/auth/forgot-password and /api/auth/reset-password (both permitAll)
- AuthE2EController (@Profile("e2e")): GET /api/auth/reset-token-for-test for CI testing
- spring-boot-starter-mail dependency; JavaMailSender optional (@Autowired required=false)
- mail health indicator disabled; mail config via MAIL_HOST/PORT/USERNAME/PASSWORD env vars
- 5 unit tests written TDD-style (all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 07:26:35 +01:00
Marcel
6400cef390 fix(e2e): fix tag rename and flaky logout tests
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m11s
CI / Backend Unit Tests (pull_request) Successful in 2m7s
CI / E2E Tests (pull_request) Successful in 19m47s
CI / Unit & Component Tests (push) Successful in 2m2s
CI / Backend Unit Tests (push) Successful in 2m3s
CI / E2E Tests (push) Failing after 15m22s
admin.spec.ts: after clicking "Schlagwort bearbeiten", Svelte's {#if editingTagId}
replaces the span with a form, so familieRow (filtered by the span) no longer matches.
Find input[name="name"] and the save button directly instead.

auth.spec.ts: dropdown opens via {#if userMenuOpen} which renders asynchronously.
Wait for the Abmelden button to be visible before clicking to prevent a race condition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 07:25:34 +01:00
Marcel
f98792f10b fix(permissions): redirect read-only users from /documents/new to home
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 2m5s
CI / Backend Unit Tests (push) Successful in 2m0s
CI / E2E Tests (push) Failing after 21m36s
throw error(403) kept the URL at /documents/new (the error page renders
in-place). Changed to throw redirect(303, '/') so the URL actually changes,
matching the E2E test expectation that a read-only user is redirected away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:01:45 +01:00
Marcel
70d858b65a fix(tests): add missing user/canWrite/form props to admin spec fixtures
After the layout load function started injecting user+canWrite into all
page data, the admin spec files failed svelte-check with missing property
errors. Add user:undefined, canWrite:true, and form:null to all fixture
data objects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:01:25 +01:00
Marcel
c1e82a7edf fix(e2e): fix 8 failing E2E tests on feat/35-profile-page
- admin: add exact:true to tab button assertions to avoid strict-mode
  violations from "Benutzer löschen" title buttons matching "Benutzer"
- admin: change tag-row locator from hasText regex on <li> to has: span
  filter (more robust against whitespace differences); add waitForSelector
  after tab click to ensure panel is rendered before hovering
- auth: replace page.request.get('/api/users/me') with a profile page
  navigation — direct browser requests don't carry Basic Auth, only
  server-side SvelteKit fetches do
- documents: use getByRole('heading') instead of getByText to avoid strict
  mode violation when the title appears in both h1 and breadcrumb
- persons: same heading fix for person creation landing page
- profile: remove success-message assertion after password change; the
  auth_token cookie still holds old credentials so use:enhance's update()
  immediately gets a 401 and redirects to /login before the message renders
  — test now asserts the redirect directly, then re-logs in

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:01:04 +01:00
Marcel
7fbfeb3b39 chore(hooks): remove pre-push E2E hook
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m10s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Failing after 25m47s
E2E tests run on CI anyway — running them locally before every push
adds too much friction. Removed the hook; CI remains the safety net.

Refs #48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 22:15:00 +01:00
Marcel
bbac351f03 test(e2e): add read-only user permissions journey
Logs in as the seeded "reader" user (READ_ALL only) and asserts
that all write controls are absent from every page.

Refs #48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:01:04 +01:00
Marcel
2411c330a2 test(e2e): add admin management journey (users, groups, tags)
Full lifecycle: create group → create user → edit user → reset
password → verify login → delete user → delete group → rename tag.
Self-contained: everything created is also deleted.

Refs #48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:00:41 +01:00
Marcel
7d095e159e test(e2e): add profile page journey (view, update, password change)
Includes self-healing password change test that restores admin123
at the end so the shared session remains valid for subsequent specs.

Refs #48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:00:23 +01:00
Marcel
ca73777010 test(e2e): add person creation journey
Refs #48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:00:03 +01:00
Marcel
0221382c8a test(e2e): add document creation and edit mutation journeys
Refs #48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:59:46 +01:00
Marcel
ea6b727e44 test(e2e): verify login establishes a working API session
Guards against regressions where the session cookie is set but
the backend rejects it — a URL redirect alone is not enough.

Refs #48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:59:27 +01:00
Marcel
2a46136f61 test(e2e): seed read-only "reader" user in e2e profile
Adds a "Leser" group (READ_ALL only) and "reader" / "reader123"
user to the deterministic e2e seed so the permissions spec can log
in as a read-only user without relying on admin-created test data.

Refs #48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:59:07 +01:00
Marcel
c0b9d979ea fix(e2e): wait for swapped senderId in URL instead of any senderId
waitForURL(/senderId=/) resolved immediately because the URL already
contained senderId= before the swap navigation. Use a predicate that
waits for the specific swapped ID value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:44:54 +01:00
Marcel
c84bb3ca7b fix(e2e): open avatar dropdown before clicking logout button
The logout action was moved into a user avatar dropdown in the nav.
The E2E test was clicking the now-hidden button directly.

Refs #35
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:44:35 +01:00
Marcel
cf8425d744 docs(collab): add user journey and E2E scenario requirements
Every feature issue must include a User Journey and E2E Scenarios
section before implementation begins.

Refs #48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:44:18 +01:00
Marcel
1fcd8a6ad6 chore(hooks): run E2E tests before every push
Adds a Husky pre-push hook so `npm run test:e2e` must pass before any
push is accepted. The login regression in 8f5c13f would have been caught
immediately had this gate been in place.

Closes #48 (enforcement side — coverage gaps tracked separately).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:34:45 +01:00
Marcel
fb4f8e820c feat(admin): add dedicated routes for admin user management (#37)
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m4s
CI / Backend Unit Tests (push) Successful in 1m59s
CI / E2E Tests (push) Failing after 18m4s
CI / Unit & Component Tests (pull_request) Successful in 2m2s
CI / Backend Unit Tests (pull_request) Successful in 2m0s
CI / E2E Tests (pull_request) Failing after 16m10s
- New GET /admin/users/new page: create user with all profile fields
  (login, password, firstName, lastName, birthDate, email, contact, groups)
- New GET /admin/users/[id] page: edit user profile, groups, and
  optional password change without requiring current password
- New PUT /api/users/{id} backend endpoint (ADMIN_USER permission)
  with AdminUpdateUserRequest DTO for admin-override user updates
- Refactored admin users tab: replaced inline editing with edit links
  to dedicated routes; create button now links to /admin/users/new
- Extended CreateUserRequest with profile fields so new users can be
  created with full profile data in a single request
- Added 28 component tests across 3 new spec files (TDD)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 16:33:50 +01:00
114 changed files with 8954 additions and 333 deletions

View File

@@ -190,6 +190,7 @@ jobs:
E2E_BASE_URL: http://localhost:3000
E2E_USERNAME: admin
E2E_PASSWORD: admin123
E2E_BACKEND_URL: http://localhost:8080
- name: Upload E2E results
if: always()

View File

@@ -43,6 +43,42 @@ Repeat for each new behavior.
- The Refactor step must not change behavior — if a test breaks, the refactor introduced a bug.
- If a bug is reported with no test, write the failing test first, then fix it.
## User Journeys & E2E Acceptance Criteria
Every `feature` issue must include two sections before any implementation begins:
### 1. User Journey
A plain-prose description of the steps a user takes to get value from the feature. Written from the user's perspective, not the implementation's:
> User opens a document, clicks "History", sees a chronological list of changes with editor name and timestamp. Clicking a row expands the old vs. new values.
This makes the scope concrete and prevents scope creep — anything not in the journey is out of scope for the issue.
### 2. E2E Scenarios
One or more acceptance criteria written as Playwright-ready scenarios. These become the outermost Red test in the TDD cycle — no feature is considered done until all its E2E scenarios pass:
```
Scenario: View edit history of a document
Given I am on a document detail page
When I click the "History" tab
Then I see at least one revision entry
And each entry shows the editor's name and a timestamp
```
Use this format consistently. It maps directly to `test.describe` / `test` blocks in the Playwright spec.
### Where this fits in the workflow
```
Issue (Journey + Scenarios) → Red E2E test → Implementation → Green
```
The scenarios in the issue are the contract. Write them before planning, treat them as failing tests from day one.
---
## Issue Tracking (Gitea)
All work is tracked in **Gitea** at `http://192.168.178.71:3005` (repo `marcel/familienarchiv`). Never use todo files or CLAUDE.md notes as a substitute.

View File

@@ -119,6 +119,10 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
@@ -144,7 +148,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<spring.profiles.active>dev</spring.profiles.active>
<spring.profiles.active>dev,e2e</spring.profiles.active>
</properties>
</profile>
<profile>

View File

@@ -6,10 +6,12 @@ import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig {
@Bean
public Executor taskExecutor() {

View File

@@ -43,13 +43,13 @@ public class DataInitializer {
@Bean
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
return args -> {
if (userRepository.count() == 0) {
log.info("Keine User gefunden. Erstelle Default-Admin...");
if (userRepository.findByUsername(adminUsername).isEmpty()) {
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminUsername);
// 1. Admin Gruppe erstellen
UserGroup adminGroup = UserGroup.builder()
.name("Administrators")
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
.build();
groupRepository.save(adminGroup);
@@ -81,10 +81,27 @@ public class DataInitializer {
@Profile("e2e")
public CommandLineRunner initE2EData(PersonRepository personRepo,
DocumentRepository docRepo,
TagRepository tagRepo) {
TagRepository tagRepo,
PasswordEncoder passwordEncoder) {
return args -> {
// Always ensure the read-only test user exists, even when seed data was already loaded.
if (userRepository.findByUsername("reader").isEmpty()) {
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
groupRepository.save(UserGroup.builder()
.name("Leser")
.permissions(Set.of("READ_ALL"))
.build()));
userRepository.save(AppUser.builder()
.username("reader")
.password(passwordEncoder.encode("reader123"))
.groups(Set.of(leserGroup))
.build());
log.info("E2E seed: 'reader'-Testbenutzer erstellt.");
}
if (personRepo.count() > 0) {
log.info("E2E seed: Daten bereits vorhanden, überspringe.");
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
return;
}
@@ -165,8 +182,8 @@ public class DataInitializer {
.receivers(Set.of(otto))
.build());
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente erstellt.",
personRepo.count(), tagRepo.count(), docRepo.count());
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.",
personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count());
};
}
}

View File

@@ -48,6 +48,10 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> {
// Health endpoint must be open so CI/Docker health checks work without credentials
auth.requestMatchers("/actuator/health").permitAll();
// Password reset endpoints are unauthenticated by nature
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
// E2E test helper (only active under "e2e" profile)
auth.requestMatchers("/api/auth/reset-token-for-test").permitAll();
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
if (environment.matchesProfiles("dev")) {
auth.requestMatchers(

View File

@@ -1,7 +1,10 @@
package org.raddatz.familienarchiv.controller;
import org.raddatz.familienarchiv.dto.BackfillResult;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
import org.raddatz.familienarchiv.service.MassImportService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -18,6 +21,8 @@ import lombok.RequiredArgsConstructor;
public class AdminController {
private final MassImportService massImportService;
private final DocumentService documentService;
private final DocumentVersionService documentVersionService;
@PostMapping("/trigger-import")
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
@@ -29,4 +34,17 @@ public class AdminController {
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
return ResponseEntity.ok(massImportService.getStatus());
}
@PostMapping("/backfill-versions")
public ResponseEntity<BackfillResult> backfillVersions() {
int count = documentVersionService.backfillMissingVersions(
documentService.getDocumentsWithoutVersions());
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

@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.controller;
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;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/documents/{documentId}/annotations")
@RequiredArgsConstructor
@Slf4j
public class AnnotationController {
private final AnnotationService annotationService;
private final DocumentService documentService;
private final UserService userService;
@GetMapping
public List<DocumentAnnotation> listAnnotations(@PathVariable UUID documentId) {
return annotationService.listAnnotations(documentId);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.ANNOTATE_ALL)
public DocumentAnnotation createAnnotation(
@PathVariable UUID documentId,
@RequestBody CreateAnnotationDTO dto,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
Document doc = documentService.getDocumentById(documentId);
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
}
@DeleteMapping("/{annotationId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.ANNOTATE_ALL)
public void deleteAnnotation(
@PathVariable UUID documentId,
@PathVariable UUID annotationId,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
annotationService.deleteAnnotation(documentId, annotationId, userId);
}
// ─── private helpers ──────────────────────────────────────────────────────
private UUID resolveUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
AppUser user = userService.findByUsername(authentication.getName());
return user != null ? user.getId() : null;
} catch (Exception e) {
log.warn("Could not resolve user for annotation: {}", e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,37 @@
package org.raddatz.familienarchiv.controller;
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
import org.raddatz.familienarchiv.service.PasswordResetService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final PasswordResetService passwordResetService;
@Value("${app.base-url:http://localhost:3000}")
private String appBaseUrl;
@PostMapping("/forgot-password")
public ResponseEntity<Void> forgotPassword(@RequestBody ForgotPasswordRequest request) {
passwordResetService.requestReset(request.getEmail(), appBaseUrl);
// Always return 204 — never disclose whether the email exists
return ResponseEntity.noContent().build();
}
@PostMapping("/reset-password")
public ResponseEntity<Void> resetPassword(@RequestBody ResetPasswordRequest request) {
passwordResetService.resetPassword(request);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,33 @@
package org.raddatz.familienarchiv.controller;
import java.time.LocalDateTime;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
/**
* Test-only endpoint to retrieve a password reset token by email.
* Only active under the "e2e" Spring profile.
*/
@RestController
@RequestMapping("/api/auth")
@Profile("e2e")
@RequiredArgsConstructor
public class AuthE2EController {
private final PasswordResetTokenRepository tokenRepository;
@GetMapping("/reset-token-for-test")
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,122 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateCommentDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.CommentService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequiredArgsConstructor
@Slf4j
public class CommentController {
private final CommentService commentService;
private final UserService userService;
// ─── General document comments ────────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/comments")
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
return commentService.getCommentsForDocument(documentId);
}
@PostMapping("/api/documents/{documentId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.ANNOTATE_ALL)
public DocumentComment postDocumentComment(
@PathVariable UUID documentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, null, dto.getContent(), author);
}
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.ANNOTATE_ALL)
public DocumentComment replyToDocumentComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
}
// ─── Annotation comments ──────────────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
return commentService.getCommentsForAnnotation(annotationId);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.ANNOTATE_ALL)
public DocumentComment postAnnotationComment(
@PathVariable UUID documentId,
@PathVariable UUID annotationId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.ANNOTATE_ALL)
public DocumentComment replyToAnnotationComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
}
// ─── Edit and delete (shared) ─────────────────────────────────────────────
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
@RequirePermission(Permission.ANNOTATE_ALL)
public DocumentComment editComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser currentUser = resolveUser(authentication);
return commentService.editComment(documentId, commentId, dto.getContent(), currentUser);
}
@DeleteMapping("/api/documents/{documentId}/comments/{commentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
Authentication authentication) {
AppUser currentUser = resolveUser(authentication);
commentService.deleteComment(documentId, commentId, currentUser);
}
// ─── private helpers ──────────────────────────────────────────────────────
private AppUser resolveUser(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
return userService.findByUsername(authentication.getName());
} catch (Exception e) {
log.warn("Could not resolve user for comment: {}", e.getMessage());
return null;
}
}
}

View File

@@ -5,13 +5,18 @@ import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
import org.raddatz.familienarchiv.service.FileService;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
@@ -39,6 +44,7 @@ import lombok.extern.slf4j.Slf4j;
public class DocumentController {
private final DocumentService documentService;
private final DocumentVersionService documentVersionService;
private final FileService fileService;
// --- DOWNLOAD ---
@@ -108,6 +114,18 @@ public class DocumentController {
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags));
}
// --- VERSIONS ---
@GetMapping("/{id}/versions")
public List<DocumentVersionSummary> getVersions(@PathVariable UUID id) {
return documentVersionService.getSummaries(id);
}
@GetMapping("/{id}/versions/{versionId}")
public DocumentVersion getVersion(@PathVariable UUID id, @PathVariable UUID versionId) {
return documentVersionService.getVersion(id, versionId);
}
@GetMapping("/conversation")
public List<Document> getConversation(
@RequestParam UUID senderId,

View File

@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
@@ -79,6 +80,15 @@ public class UserController {
return ResponseEntity.ok(userService.createUserOrUpdate(request));
}
@PutMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
@RequestBody AdminUpdateUserRequest dto) {
AppUser updated = userService.adminUpdateUser(id, dto);
updated.setPassword(null);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {

View File

@@ -0,0 +1,18 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Data
public class AdminUpdateUserRequest {
private String firstName;
private String lastName;
private LocalDate birthDate;
private String email;
private String contact;
private String newPassword;
private List<UUID> groupIds;
}

View File

@@ -0,0 +1,7 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
public record BackfillResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count
) {}

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateAnnotationDTO {
private int pageNumber;
private double x;
private double y;
private double width;
private double height;
private String color;
}

View File

@@ -0,0 +1,8 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
@Data
public class CreateCommentDTO {
private String content;
}

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.dto;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@@ -11,5 +12,9 @@ public class CreateUserRequest {
private String username;
private String email;
private String initialPassword;
private List<UUID> groupIds; // In welche Gruppen soll der User?
private List<UUID> groupIds;
private String firstName;
private String lastName;
private LocalDate birthDate;
private String contact;
}

View File

@@ -0,0 +1,14 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public record DocumentVersionSummary(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime savedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String editorName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<String> changedFields
) {}

View File

@@ -0,0 +1,8 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
@Data
public class ForgotPasswordRequest {
private String email;
}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
@Data
public class ResetPasswordRequest {
private String token;
private String newPassword;
}

View File

@@ -35,6 +35,18 @@ public enum ErrorCode {
UNAUTHORIZED,
/** The authenticated user lacks the required permission. 403 */
FORBIDDEN,
/** The password-reset token is missing, expired, or already used. 400 */
INVALID_RESET_TOKEN,
// --- Annotations ---
/** The annotation with the given ID does not exist. 404 */
ANNOTATION_NOT_FOUND,
/** The new annotation overlaps an existing one on the same page. 409 */
ANNOTATION_OVERLAP,
// --- Comments ---
/** The comment with the given ID does not exist. 404 */
COMMENT_NOT_FOUND,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */

View File

@@ -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)

View File

@@ -0,0 +1,62 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "document_annotations")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DocumentAnnotation {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "document_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID documentId;
@Column(name = "page_number", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int pageNumber;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double x;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double y;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double width;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double height;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String color;
@Column(name = "file_hash", length = 64)
private String fileHash;
@Column(name = "created_by")
private UUID createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,63 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "document_comments")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DocumentComment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "document_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID documentId;
@Column(name = "annotation_id")
private UUID annotationId;
@Column(name = "parent_id")
private UUID parentId;
@Column(name = "author_id")
private UUID authorId;
@Column(name = "author_name", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String authorName;
@Column(nullable = false, columnDefinition = "TEXT")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
@UpdateTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updatedAt;
// Populated by the service — not stored in the database
@Transient
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<DocumentComment> replies = new ArrayList<>();
}

View File

@@ -0,0 +1,50 @@
package org.raddatz.familienarchiv.model;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "document_versions")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DocumentVersion {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "document_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID documentId;
@Column(name = "saved_at", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime savedAt;
@Column(name = "editor_id")
private UUID editorId;
@Column(name = "editor_name", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String editorName;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String snapshot;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "changed_fields", columnDefinition = "jsonb", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String changedFields;
}

View File

@@ -0,0 +1,45 @@
package org.raddatz.familienarchiv.model;
import java.time.LocalDateTime;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "password_reset_tokens")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PasswordResetToken {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private AppUser user;
@Column(nullable = false, unique = true, length = 64)
private String token;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(nullable = false)
@Builder.Default
private boolean used = false;
}

View File

@@ -0,0 +1,19 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface AnnotationRepository extends JpaRepository<DocumentAnnotation, UUID> {
List<DocumentAnnotation> findByDocumentId(UUID documentId);
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
List<DocumentAnnotation> findByDocumentIdAndFileHashIsNull(UUID documentId);
}

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
List<DocumentComment> findByParentId(UUID parentId);
}

View File

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

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface DocumentVersionRepository extends JpaRepository<DocumentVersion, UUID> {
List<DocumentVersion> findByDocumentIdOrderBySavedAtAsc(UUID documentId);
Optional<DocumentVersion> findByIdAndDocumentId(UUID id, UUID documentId);
}

View File

@@ -0,0 +1,22 @@
package org.raddatz.familienarchiv.repository;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, UUID> {
Optional<PasswordResetToken> findByToken(String token);
@Query("SELECT t.token FROM PasswordResetToken t WHERE t.user.email = :email AND t.used = false AND t.expiresAt > :now ORDER BY t.expiresAt DESC LIMIT 1")
Optional<String> findLatestActiveTokenByEmail(String email, LocalDateTime now);
@Modifying
@Query("DELETE FROM PasswordResetToken t WHERE t.expiresAt < :now OR t.used = true")
void deleteExpiredAndUsed(LocalDateTime now);
}

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.security;
public enum Permission {
READ_ALL,
WRITE_ALL,
ANNOTATE_ALL,
ADMIN,
ADMIN_USER,
ADMIN_TAG,

View File

@@ -0,0 +1,83 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class AnnotationService {
private final AnnotationRepository annotationRepository;
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
return annotationRepository.findByDocumentId(documentId);
}
@Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
List<DocumentAnnotation> existing =
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
boolean overlaps = existing.stream().anyMatch(a -> overlaps(a, dto));
if (overlaps) {
throw DomainException.conflict(
ErrorCode.ANNOTATION_OVERLAP, "Annotation overlaps an existing one on this page");
}
DocumentAnnotation annotation = DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(dto.getPageNumber())
.x(dto.getX())
.y(dto.getY())
.width(dto.getWidth())
.height(dto.getHeight())
.color(dto.getColor())
.fileHash(fileHash)
.createdBy(userId)
.build();
return annotationRepository.save(annotation);
}
@Transactional
public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) {
DocumentAnnotation annotation = annotationRepository
.findByIdAndDocumentId(annotationId, documentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId));
if (userId == null || !userId.equals(annotation.getCreatedBy())) {
throw DomainException.forbidden("Only the annotation author can delete it");
}
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 boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
double ex2 = existing.getX() + existing.getWidth();
double ey2 = existing.getY() + existing.getHeight();
double dx2 = dto.getX() + dto.getWidth();
double dy2 = dto.getY() + dto.getHeight();
return existing.getX() < dx2 && ex2 > dto.getX()
&& existing.getY() < dy2 && ey2 > dto.getY();
}
}

View File

@@ -0,0 +1,109 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
List<DocumentComment> roots =
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
return withReplies(roots);
}
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
return withReplies(roots);
}
@Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.annotationId(annotationId)
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
return commentRepository.save(comment);
}
@Transactional
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
DocumentComment target = commentRepository.findById(commentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
UUID rootId = target.getParentId() != null ? target.getParentId() : target.getId();
DocumentComment root = commentRepository.findById(rootId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + rootId));
DocumentComment reply = DocumentComment.builder()
.documentId(documentId)
.annotationId(root.getAnnotationId())
.parentId(root.getId())
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
return commentRepository.save(reply);
}
@Transactional
public DocumentComment editComment(UUID documentId, UUID commentId, String content, AppUser currentUser) {
DocumentComment comment = findComment(documentId, commentId);
if (!currentUser.getId().equals(comment.getAuthorId())) {
throw DomainException.forbidden("Only the comment author can edit it");
}
comment.setContent(content);
return commentRepository.save(comment);
}
@Transactional
public void deleteComment(UUID documentId, UUID commentId, AppUser currentUser) {
DocumentComment comment = findComment(documentId, commentId);
boolean isAuthor = currentUser.getId().equals(comment.getAuthorId());
boolean isAdmin = currentUser.hasPermission("ADMIN");
if (!isAuthor && !isAdmin) {
throw DomainException.forbidden("Only the comment author or an admin can delete it");
}
commentRepository.delete(comment);
}
// ─── private helpers ──────────────────────────────────────────────────────
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
return roots;
}
private DocumentComment findComment(UUID documentId, UUID commentId) {
return commentRepository.findById(commentId)
.filter(c -> documentId.equals(c.getDocumentId()))
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
}
private String resolveAuthorName(AppUser author) {
String first = author.getFirstName();
String last = author.getLastName();
if ((first == null || first.isBlank()) && (last == null || last.isBlank())) {
return author.getUsername();
}
return ((first != null ? first : "") + " " + (last != null ? last : "")).strip();
}
}

View File

@@ -18,6 +18,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
@@ -37,6 +39,8 @@ public class DocumentService {
private final PersonService personService;
private final FileService fileService;
private final TagService tagService;
private final DocumentVersionService documentVersionService;
private final AnnotationService annotationService;
/**
* Lädt eine Datei hoch.
@@ -63,10 +67,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);
@@ -119,13 +124,16 @@ 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);
}
return documentRepository.save(doc);
Document finalDoc = documentRepository.save(doc);
documentVersionService.recordVersion(finalDoc);
return finalDoc;
}
@Transactional
@@ -167,18 +175,17 @@ 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);
}
return documentRepository.save(doc);
Document saved = documentRepository.save(doc);
documentVersionService.recordVersion(saved);
return saved;
}
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
@@ -252,6 +259,10 @@ public class DocumentService {
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
}
public List<Document> getDocumentsWithoutVersions() {
return documentRepository.findDocumentsWithoutVersions();
}
public List<Document> getDocumentsBySender(UUID senderId) {
return documentRepository.findBySenderId(senderId);
}
@@ -274,4 +285,39 @@ public class DocumentService {
});
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

@@ -0,0 +1,229 @@
package org.raddatz.familienarchiv.service;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentVersionRepository;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class DocumentVersionService {
private final DocumentVersionRepository versionRepository;
private final UserService userService;
private final ObjectMapper objectMapper;
@Transactional
public void recordVersion(Document doc) {
AppUser editor = resolveCurrentUser();
String editorName = buildEditorName(editor);
UUID editorId = editor != null ? editor.getId() : null;
String snapshot = serializeSnapshot(doc);
List<DocumentVersion> previous = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId());
String changedFields = computeChangedFields(doc, previous);
versionRepository.save(DocumentVersion.builder()
.documentId(doc.getId())
.savedAt(LocalDateTime.now())
.editorId(editorId)
.editorName(editorName)
.snapshot(snapshot)
.changedFields(changedFields)
.build());
}
@Transactional
public int backfillMissingVersions(List<Document> docs) {
int count = 0;
for (Document doc : docs) {
List<DocumentVersion> existing = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId());
if (!existing.isEmpty()) continue;
LocalDateTime savedAt = doc.getCreatedAt() != null ? doc.getCreatedAt() : LocalDateTime.now();
versionRepository.save(DocumentVersion.builder()
.documentId(doc.getId())
.savedAt(savedAt)
.editorId(null)
.editorName("Datenimport")
.snapshot(serializeSnapshot(doc))
.changedFields("[]")
.build());
count++;
}
return count;
}
public List<DocumentVersionSummary> getSummaries(UUID documentId) {
return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream()
.map(v -> new DocumentVersionSummary(
v.getId(),
v.getSavedAt(),
v.getEditorName(),
parseChangedFields(v.getChangedFields())))
.toList();
}
public DocumentVersion getVersion(UUID documentId, UUID versionId) {
return versionRepository.findByIdAndDocumentId(versionId, documentId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND,
"Version not found: " + versionId));
}
// ─── private helpers ──────────────────────────────────────────────────────
private AppUser resolveCurrentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return null;
}
try {
return userService.findByUsername(auth.getName());
} catch (Exception e) {
log.warn("Could not resolve editor for version snapshot: {}", e.getMessage());
return null;
}
}
private String buildEditorName(AppUser user) {
if (user == null) return "Unknown";
String first = user.getFirstName();
String last = user.getLastName();
if (first != null && !first.isBlank() && last != null && !last.isBlank()) {
return first + " " + last;
}
return user.getUsername();
}
private String serializeSnapshot(Document doc) {
try {
return objectMapper.writeValueAsString(doc);
} catch (Exception e) {
log.error("Failed to serialize document snapshot for {}", doc.getId(), e);
return "{}";
}
}
private String computeChangedFields(Document current, List<DocumentVersion> previousVersions) {
if (previousVersions.isEmpty()) {
return "[]";
}
DocumentVersion last = previousVersions.get(previousVersions.size() - 1);
try {
Map<String, Object> previousMap = objectMapper.readValue(
last.getSnapshot(), new TypeReference<>() {});
List<String> changed = new ArrayList<>();
checkScalar(changed, "title", current.getTitle(), previousMap);
checkScalar(changed, "documentDate",
current.getDocumentDate() != null ? current.getDocumentDate().toString() : null,
previousMap);
checkScalar(changed, "location", current.getLocation(), previousMap);
checkScalar(changed, "documentLocation", current.getDocumentLocation(), previousMap);
checkScalar(changed, "transcription", current.getTranscription(), previousMap);
checkScalar(changed, "summary", current.getSummary(), previousMap);
checkSender(changed, current, previousMap);
checkReceivers(changed, current, previousMap);
checkTags(changed, current, previousMap);
return objectMapper.writeValueAsString(changed);
} catch (Exception e) {
log.warn("Could not compute changedFields for document {}", current.getId(), e);
return "[]";
}
}
private void checkScalar(List<String> changed, String field, String currentValue,
Map<String, Object> previousMap) {
Object prev = previousMap.get(field);
String prevStr = prev != null ? prev.toString() : null;
if (!Objects.equals(currentValue, prevStr)) {
changed.add(field);
}
}
@SuppressWarnings("unchecked")
private void checkSender(List<String> changed, Document current, Map<String, Object> previousMap) {
String currentId = current.getSender() != null
? current.getSender().getId().toString() : null;
Object prevSender = previousMap.get("sender");
String prevId = null;
if (prevSender instanceof Map<?, ?> senderMap) {
Object id = senderMap.get("id");
prevId = id != null ? id.toString() : null;
}
if (!Objects.equals(currentId, prevId)) {
changed.add("sender");
}
}
@SuppressWarnings("unchecked")
private void checkReceivers(List<String> changed, Document current, Map<String, Object> previousMap) {
Set<String> currentIds = current.getReceivers() != null
? current.getReceivers().stream().map(p -> p.getId().toString()).collect(Collectors.toSet())
: Set.of();
Object prevReceivers = previousMap.get("receivers");
Set<String> prevIds = Set.of();
if (prevReceivers instanceof List<?> list) {
prevIds = list.stream()
.filter(r -> r instanceof Map<?, ?>)
.map(r -> ((Map<?, ?>) r).get("id"))
.filter(Objects::nonNull)
.map(Object::toString)
.collect(Collectors.toSet());
}
if (!currentIds.equals(prevIds)) {
changed.add("receivers");
}
}
@SuppressWarnings("unchecked")
private void checkTags(List<String> changed, Document current, Map<String, Object> previousMap) {
Set<String> currentNames = current.getTags() != null
? current.getTags().stream().map(Tag::getName).collect(Collectors.toSet())
: Set.of();
Object prevTags = previousMap.get("tags");
Set<String> prevNames = Set.of();
if (prevTags instanceof List<?> list) {
prevNames = list.stream()
.filter(t -> t instanceof Map<?, ?>)
.map(t -> ((Map<?, ?>) t).get("name"))
.filter(Objects::nonNull)
.map(Object::toString)
.collect(Collectors.toSet());
}
if (!currentNames.equals(prevNames)) {
changed.add("tags");
}
}
private List<String> parseChangedFields(String json) {
try {
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return List.of();
}
}
}

View File

@@ -13,6 +13,9 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.InputStreamResource;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
@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 {
// 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 +49,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 +64,72 @@ 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<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();
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
/**
* 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) {}
// Custom Exception
public static class StorageFileNotFoundException extends RuntimeException {
public StorageFileNotFoundException(String message) { super(message); }
}

View File

@@ -0,0 +1,132 @@
package org.raddatz.familienarchiv.service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Optional;
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Service
@RequiredArgsConstructor
@Slf4j
public class PasswordResetService {
private final AppUserRepository userRepository;
private final PasswordResetTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
@Autowired(required = false)
private JavaMailSender mailSender;
@Value("${app.mail.from:noreply@familienarchiv.local}")
private String mailFrom;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final int TOKEN_EXPIRY_HOURS = 1;
/**
* Creates a reset token for the given email address and sends it via email.
* If the email is not found, silently does nothing (prevents user enumeration).
* If no mail sender is configured, logs a warning.
*/
public void requestReset(String email, String appBaseUrl) {
Optional<AppUser> userOpt = userRepository.findByEmail(email);
if (userOpt.isEmpty()) {
log.debug("Password reset requested for unknown email: {}", email);
return;
}
AppUser user = userOpt.get();
String token = generateToken();
tokenRepository.save(PasswordResetToken.builder()
.user(user)
.token(token)
.expiresAt(LocalDateTime.now().plusHours(TOKEN_EXPIRY_HOURS))
.build());
sendResetEmail(user.getEmail(), token, appBaseUrl);
}
/**
* Validates the token and updates the user's password.
*/
@Transactional
public void resetPassword(ResetPasswordRequest request) {
PasswordResetToken resetToken = tokenRepository.findByToken(request.getToken())
.orElseThrow(() -> DomainException.badRequest(
ErrorCode.INVALID_RESET_TOKEN, "Invalid or unknown reset token"));
if (resetToken.isUsed() || resetToken.getExpiresAt().isBefore(LocalDateTime.now())) {
throw DomainException.badRequest(ErrorCode.INVALID_RESET_TOKEN, "Token expired or already used");
}
AppUser user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);
resetToken.setUsed(true);
tokenRepository.save(resetToken);
}
/** Nightly cleanup of expired and used tokens. */
@Scheduled(cron = "0 0 3 * * *")
@Transactional
public void cleanupExpiredTokens() {
tokenRepository.deleteExpiredAndUsed(LocalDateTime.now());
log.info("Cleaned up expired password reset tokens");
}
private String generateToken() {
byte[] bytes = new byte[32];
SECURE_RANDOM.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private void sendResetEmail(String to, String token, String appBaseUrl) {
if (mailSender == null) {
log.warn("Mail sender not configured — skipping password reset email to {}", to);
return;
}
String resetUrl = appBaseUrl + "/reset-password?token=" + token;
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(to);
message.setSubject("Passwort zurücksetzen — Familienarchiv");
message.setText(
"Hallo,\n\n"
+ "Sie haben eine Passwort-Zurücksetzung beantragt.\n\n"
+ "Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:\n"
+ resetUrl + "\n\n"
+ "Der Link ist " + TOKEN_EXPIRY_HOURS + " Stunde(n) gültig.\n\n"
+ "Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n"
+ "Ihr Familienarchiv-Team");
try {
mailSender.send(message);
log.info("Password reset email sent to {}", to);
} catch (MailException e) {
log.error("Failed to send password reset email to {}: {}", to, e.getMessage());
}
}
}

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
@@ -54,6 +55,10 @@ public class UserService {
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
.firstName(request.getFirstName())
.lastName(request.getLastName())
.birthDate(request.getBirthDate())
.contact(request.getContact())
.enabled(true)
.build();
}
@@ -96,6 +101,39 @@ public class UserService {
return userRepository.save(user);
}
@Transactional
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
AppUser user = getById(id);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
if (!existing.getId().equals(id)) {
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE,
"E-Mail wird bereits von einem anderen Konto verwendet");
}
});
user.setEmail(dto.getEmail().trim());
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) {
user.setEmail(null);
}
user.setFirstName(dto.getFirstName());
user.setLastName(dto.getLastName());
user.setBirthDate(dto.getBirthDate());
user.setContact(dto.getContact() == null || dto.getContact().isBlank() ? null : dto.getContact().trim());
if (dto.getNewPassword() != null && !dto.getNewPassword().isBlank()) {
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
}
if (dto.getGroupIds() != null) {
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(groups);
}
return userRepository.save(user);
}
@Transactional
public void changePassword(UUID userId, ChangePasswordDTO dto) {
AppUser user = getById(userId);

View File

@@ -24,6 +24,23 @@ spring:
max-file-size: 50MB
max-request-size: 50MB
mail:
host: ${MAIL_HOST:}
port: ${MAIL_PORT:587}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
management:
health:
mail:
enabled: false
springdoc:
api-docs:
enabled: false
@@ -38,6 +55,11 @@ app:
bucket: ${S3_BUCKET_NAME}
region: ${S3_REGION}
base-url: ${APP_BASE_URL:http://localhost:3000}
mail:
from: ${APP_MAIL_FROM:noreply@familienarchiv.local}
admin:
username: ${APP_ADMIN_USERNAME:admin}
password: ${APP_ADMIN_PASSWORD:admin123}

View File

@@ -0,0 +1,14 @@
CREATE TABLE document_annotations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
width DOUBLE PRECISION NOT NULL,
height DOUBLE PRECISION NOT NULL,
color VARCHAR(20) NOT NULL,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX ON document_annotations (document_id, page_number);

View File

@@ -0,0 +1,7 @@
-- Grant ANNOTATE_ALL to every group that already has ADMIN.
-- New installs get it via DataInitializer; this covers existing deployments.
INSERT INTO group_permissions (group_id, permission)
SELECT g.id, 'ANNOTATE_ALL'
FROM user_groups g
WHERE g.id IN (SELECT group_id FROM group_permissions WHERE permission = 'ADMIN')
AND g.id NOT IN (SELECT group_id FROM group_permissions WHERE permission = 'ANNOTATE_ALL');

View File

@@ -0,0 +1,15 @@
CREATE TABLE document_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
annotation_id UUID REFERENCES document_annotations(id) ON DELETE CASCADE,
parent_id UUID REFERENCES document_comments(id) ON DELETE CASCADE,
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_dc_document ON document_comments(document_id);
CREATE INDEX idx_dc_annotation ON document_comments(annotation_id);
CREATE INDEX idx_dc_parent ON document_comments(parent_id);

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

@@ -0,0 +1,10 @@
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_prt_token ON password_reset_tokens(token);

View File

@@ -0,0 +1,11 @@
CREATE TABLE document_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
saved_at TIMESTAMP NOT NULL DEFAULT now(),
editor_id UUID REFERENCES users(id) ON DELETE SET NULL,
editor_name VARCHAR(200) NOT NULL,
snapshot JSONB NOT NULL,
changed_fields JSONB NOT NULL DEFAULT '[]'
);
CREATE INDEX ON document_versions (document_id, saved_at DESC);

View File

@@ -0,0 +1,86 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
import org.raddatz.familienarchiv.service.MassImportService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(AdminController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class AdminControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean MassImportService massImportService;
@MockitoBean DocumentService documentService;
@MockitoBean DocumentVersionService documentVersionService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Test
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-versions"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void backfillVersions_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/admin/backfill-versions"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN")
void backfillVersions_returns200_withCount_whenAdmin() throws Exception {
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
mockMvc.perform(post("/api/admin/backfill-versions"))
.andExpect(status().isOk())
.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

@@ -0,0 +1,135 @@
package org.raddatz.familienarchiv.controller;
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;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(AnnotationController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class AnnotationControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean AnnotationService annotationService;
@MockitoBean DocumentService documentService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final String ANNOTATION_JSON =
"{\"pageNumber\":1,\"x\":0.1,\"y\":0.1,\"width\":0.2,\"height\":0.2,\"color\":\"#ff0000\"}";
// ─── GET /api/documents/{documentId}/annotations ──────────────────────────
@Test
void listAnnotations_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void listAnnotations_returns200_whenAuthenticated() throws Exception {
when(annotationService.listAnnotations(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/annotations ─────────────────────────
@Test
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
UUID docId = UUID.randomUUID();
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(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)
.content(ANNOTATION_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.pageNumber").value(1));
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns409_whenOverlap() throws Exception {
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")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isConflict());
}
// ─── DELETE /api/documents/{documentId}/annotations/{annotationId} ─────────
@Test
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isNoContent());
}
}

View File

@@ -0,0 +1,203 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CommentService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(CommentController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class CommentControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean CommentService commentService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
private static final UUID DOC_ID = UUID.randomUUID();
private static final UUID ANN_ID = UUID.randomUUID();
private static final UUID COMMENT_ID = UUID.randomUUID();
// ─── GET /api/documents/{documentId}/comments ─────────────────────────────
@Test
void getDocumentComments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getDocumentComments_returns200_whenAuthenticated() throws Exception {
when(commentService.getCommentsForDocument(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/comments ────────────────────────────
@Test
void postDocumentComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void postDocumentComment_returns403_whenMissingPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postDocumentComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.content").value("Test comment"));
}
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
@Test
void replyToComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
.authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── PATCH /api/documents/{documentId}/comments/{commentId} ──────────────
@Test
void editComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void editComment_returns200_whenHasPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
// ─── DELETE /api/documents/{documentId}/comments/{commentId} ─────────────
@Test
void deleteComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteComment_returns204_whenAuthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isNoContent());
}
// ─── GET /api/documents/{documentId}/annotations/{annId}/comments ─────────
@Test
void getAnnotationComments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getAnnotationComments_returns200_whenAuthenticated() throws Exception {
when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
@Test
@WithMockUser
void postAnnotationComment_returns403_whenMissingPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postAnnotationComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToAnnotationComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
}

View File

@@ -1,10 +1,13 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
import org.raddatz.familienarchiv.service.FileService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -15,13 +18,16 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(DocumentController.class)
@@ -31,6 +37,7 @@ class DocumentControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean DocumentService documentService;
@MockitoBean DocumentVersionService documentVersionService;
@MockitoBean FileService fileService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@@ -113,4 +120,48 @@ class DocumentControllerTest {
.with(req -> { req.setMethod("PUT"); return req; }))
.andExpect(status().isOk());
}
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
@Test
void getVersions_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getVersions_returns200_whenAuthenticated() throws Exception {
UUID docId = UUID.randomUUID();
DocumentVersionSummary summary = new DocumentVersionSummary(
UUID.randomUUID(), LocalDateTime.now(), "Emma Müller", List.of("title"));
when(documentVersionService.getSummaries(docId)).thenReturn(List.of(summary));
mockMvc.perform(get("/api/documents/" + docId + "/versions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].editorName").value("Emma Müller"));
}
// ─── GET /api/documents/{id}/versions/{versionId} ────────────────────────
@Test
void getVersion_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getVersion_returns200_whenAuthenticated() throws Exception {
UUID docId = UUID.randomUUID();
UUID versionId = UUID.randomUUID();
DocumentVersion version = DocumentVersion.builder()
.id(versionId).documentId(docId).savedAt(LocalDateTime.now())
.editorName("Otto").snapshot("{\"title\":\"Brief\"}").changedFields("[]").build();
when(documentVersionService.getVersion(docId, versionId)).thenReturn(version);
mockMvc.perform(get("/api/documents/" + docId + "/versions/" + versionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.editorName").value("Otto"));
}
}

View File

@@ -0,0 +1,186 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@ExtendWith(MockitoExtension.class)
class AnnotationServiceTest {
@Mock AnnotationRepository annotationRepository;
@InjectMocks AnnotationService annotationService;
// ─── createAnnotation ─────────────────────────────────────────────────────
@Test
void createAnnotation_throwsConflict_whenAnnotationOverlapsExisting() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.3, 0.3, "#ff0000");
DocumentAnnotation existing = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.2).y(0.2).width(0.3).height(0.3).color("#00ff00").build();
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
.thenReturn(List.of(existing));
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
verify(annotationRepository, never()).save(any());
}
@Test
void createAnnotation_savesAndReturns_whenNoOverlap() {
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());
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.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, null);
assertThat(result).isEqualTo(saved);
verify(annotationRepository).save(any());
}
// ─── deleteAnnotation ─────────────────────────────────────────────────────
@Test
void deleteAnnotation_throwsNotFound_whenMissing() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, UUID.randomUUID()))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
}
@Test
void deleteAnnotation_throwsForbidden_whenNotOwner() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID otherId = UUID.randomUUID();
DocumentAnnotation annotation = DocumentAnnotation.builder()
.id(annotId).documentId(docId).createdBy(ownerId).build();
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
.thenReturn(Optional.of(annotation));
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, otherId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
verify(annotationRepository, never()).delete(any());
}
@Test
void deleteAnnotation_succeeds_whenOwner() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
DocumentAnnotation annotation = DocumentAnnotation.builder()
.id(annotId).documentId(docId).createdBy(ownerId).build();
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
.thenReturn(Optional.of(annotation));
annotationService.deleteAnnotation(docId, annotId, ownerId);
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
void listAnnotations_returnsAllForDocument() {
UUID docId = UUID.randomUUID();
DocumentAnnotation a = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).build();
when(annotationRepository.findByDocumentId(docId)).thenReturn(List.of(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

@@ -0,0 +1,249 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.CommentRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@ExtendWith(MockitoExtension.class)
class CommentServiceTest {
@Mock CommentRepository commentRepository;
@InjectMocks CommentService commentService;
// ─── postComment ──────────────────────────────────────────────────────────
@Test
void postComment_capturesAuthorNameAtWriteTime() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder()
.id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("Müller").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Test", author);
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
}
@Test
void postComment_fallsBackToUsername_whenNamesAreBlank() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans42").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Test", author);
assertThat(result.getAuthorName()).isEqualTo("hans42");
}
// ─── replyToComment ───────────────────────────────────────────────────────
@Test
void replyToComment_throwsNotFound_whenTargetCommentMissing() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
verify(commentRepository, never()).save(any());
}
@Test
void replyToComment_resolvesToRootParent_whenReplyingToAReply() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
UUID replyId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
DocumentComment existingReply = DocumentComment.builder()
.id(replyId).documentId(docId).parentId(rootId).content("Reply1").authorName("Anna").build();
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
assertThat(result.getParentId()).isEqualTo(rootId);
}
@Test
void replyToComment_usesDirectComment_whenReplyingToTopLevel() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author);
assertThat(result.getParentId()).isEqualTo(rootId);
}
// ─── editComment ──────────────────────────────────────────────────────────
@Test
void editComment_throwsForbidden_whenNotAuthor() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
assertThatThrownBy(() -> commentService.editComment(docId, commentId, "Changed", other))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
verify(commentRepository, never()).save(any());
}
@Test
void editComment_updatesContent_whenAuthor() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID authorId = UUID.randomUUID();
AppUser author = AppUser.builder().id(authorId).username("hans").build();
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(authorId)
.content("Original").authorName("Hans").createdAt(created).build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
assertThat(result.getContent()).isEqualTo("Updated");
assertThat(result.getCreatedAt()).isEqualTo(created);
}
// ─── deleteComment ────────────────────────────────────────────────────────
@Test
void deleteComment_throwsForbidden_whenNotAuthorAndNotAdmin() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
assertThatThrownBy(() -> commentService.deleteComment(docId, commentId, other))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
verify(commentRepository, never()).delete(any());
}
@Test
void deleteComment_succeeds_whenAuthor() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID authorId = UUID.randomUUID();
AppUser author = AppUser.builder().id(authorId).username("hans").build();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
commentService.deleteComment(docId, commentId, author);
verify(commentRepository).delete(comment);
}
@Test
void deleteComment_succeeds_whenAdmin() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
AppUser admin = buildAdmin();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
commentService.deleteComment(docId, commentId, admin);
verify(commentRepository).delete(comment);
}
// ─── getCommentsForDocument ───────────────────────────────────────────────
@Test
void getCommentsForDocument_returnsRootsWithRepliesAttached() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).authorName("Hans").content("Root").build();
DocumentComment reply = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build();
when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId))
.thenReturn(List.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply));
List<DocumentComment> result = commentService.getCommentsForDocument(docId);
assertThat(result).hasSize(1);
assertThat(result.get(0).getReplies()).containsExactly(reply);
}
// ─── helpers ──────────────────────────────────────────────────────────────
private AppUser buildAdmin() {
return AppUser.builder()
.id(UUID.randomUUID())
.username("admin")
.groups(Set.of(UserGroup.builder()
.id(UUID.randomUUID())
.name("admins")
.permissions(Set.of("ADMIN"))
.build()))
.build();
}
}

View File

@@ -8,6 +8,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
@@ -20,6 +21,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@@ -29,6 +31,8 @@ class DocumentServiceTest {
@Mock PersonService personService;
@Mock FileService fileService;
@Mock TagService tagService;
@Mock DocumentVersionService documentVersionService;
@Mock AnnotationService annotationService;
@InjectMocks DocumentService documentService;
// ─── getDocumentById ──────────────────────────────────────────────────────
@@ -132,4 +136,134 @@ 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<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 ───────────────────────────────────────────────────────────
@Test
void createDocument_recordsVersionAfterSave() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Neuer Brief");
Document saved = Document.builder()
.id(UUID.randomUUID()).title("Neuer Brief")
.originalFilename("Neuer Brief").status(DocumentStatus.PLACEHOLDER)
.build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
documentService.createDocument(dto, null);
verify(documentVersionService, atLeastOnce()).recordVersion(any(Document.class));
}
@Test
void updateDocument_recordsVersionAfterSave() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Alt").originalFilename("alt.pdf")
.status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
documentService.updateDocument(id, new DocumentUpdateDTO(), null);
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,397 @@
package org.raddatz.familienarchiv.service;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentVersionRepository;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DocumentVersionServiceTest {
@Mock DocumentVersionRepository versionRepository;
@Mock UserService userService;
private DocumentVersionService versionService;
@BeforeEach
void setUp() {
versionService = new DocumentVersionService(versionRepository, userService, new ObjectMapper());
}
@BeforeEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
// ─── recordVersion — editor name ─────────────────────────────────────────
@Test
void recordVersion_usesFirstAndLastName_whenBothPresent() {
authenticateAs("emma");
when(userService.findByUsername("emma")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("emma")
.firstName("Emma").lastName("Müller").build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(minimalDocument());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("Emma Müller");
}
@Test
void recordVersion_usesUsername_whenNamesAreBlank() {
authenticateAs("otto99");
when(userService.findByUsername("otto99")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("otto99")
.firstName(null).lastName(null).build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(minimalDocument());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("otto99");
}
// ─── recordVersion — snapshot ─────────────────────────────────────────────
@Test
void recordVersion_savesSnapshotContainingTitle() {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Document doc = Document.builder()
.id(UUID.randomUUID())
.title("Wichtiger Brief")
.originalFilename("brief.pdf")
.build();
versionService.recordVersion(doc);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getSnapshot()).contains("Wichtiger Brief");
assertThat(captor.getValue().getDocumentId()).isEqualTo(doc.getId());
}
// ─── recordVersion — changedFields ────────────────────────────────────────
@Test
void recordVersion_changedFieldsIsEmpty_forFirstVersion() {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(minimalDocument());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
}
@Test
void recordVersion_includesTitleInChangedFields_whenTitleChanged() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
Document oldDoc = Document.builder().id(UUID.randomUUID()).title("Alt").build();
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID())
.documentId(oldDoc.getId())
.snapshot(oldSnapshot)
.changedFields("[]")
.savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1")
.build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(oldDoc.getId()))
.thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Document updated = Document.builder().id(oldDoc.getId()).title("Neu").build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("title");
}
@Test
void recordVersion_doesNotIncludeUnchangedFields_inChangedFields() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
Document oldDoc = Document.builder().id(docId).title("Same").location("Berlin").build();
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Document updated = Document.builder().id(docId).title("Same").location("Hamburg").build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("location");
assertThat(captor.getValue().getChangedFields()).doesNotContain("title");
}
@Test
void recordVersion_tracksSenderChange() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
Document oldDoc = Document.builder().id(docId).title("T").sender(oldSender).build();
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person newSender = Person.builder().id(UUID.randomUUID()).firstName("C").lastName("D").build();
Document updated = Document.builder().id(docId).title("T").sender(newSender).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("sender");
}
@Test
void recordVersion_tracksReceiverChange() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
Person receiver1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
Document oldDoc = Document.builder().id(docId).title("T").receivers(Set.of(receiver1)).build();
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Document updated = Document.builder().id(docId).title("T").receivers(Set.of()).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("receivers");
}
@Test
void recordVersion_tracksTagChange() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
Document oldDoc = Document.builder().id(docId).title("T").tags(Set.of(tag)).build();
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Document updated = Document.builder().id(docId).title("T").tags(Set.of()).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("tags");
}
// ─── getSummaries ─────────────────────────────────────────────────────────
@Test
void getSummaries_returnsListWithParsedChangedFields() {
UUID docId = UUID.randomUUID();
DocumentVersion v = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId)
.savedAt(LocalDateTime.now()).editorName("Emma Müller")
.snapshot("{}").changedFields("[\"title\",\"location\"]")
.build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(v));
List<DocumentVersionSummary> summaries = versionService.getSummaries(docId);
assertThat(summaries).hasSize(1);
assertThat(summaries.get(0).editorName()).isEqualTo("Emma Müller");
assertThat(summaries.get(0).changedFields()).containsExactlyInAnyOrder("title", "location");
assertThat(summaries.get(0).id()).isEqualTo(v.getId());
}
// ─── getVersion ───────────────────────────────────────────────────────────
@Test
void getVersion_returnsVersion_whenFound() {
UUID docId = UUID.randomUUID();
UUID versionId = UUID.randomUUID();
DocumentVersion v = DocumentVersion.builder()
.id(versionId).documentId(docId).snapshot("{}")
.changedFields("[]").editorName("x").savedAt(LocalDateTime.now()).build();
when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.of(v));
assertThat(versionService.getVersion(docId, versionId)).isEqualTo(v);
}
@Test
void getVersion_throwsNotFound_whenVersionBelongsToOtherDocument() {
UUID docId = UUID.randomUUID();
UUID versionId = UUID.randomUUID();
when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> versionService.getVersion(docId, versionId))
.isInstanceOf(DomainException.class);
}
// ─── backfillMissingVersions ──────────────────────────────────────────────
@Test
void backfill_createsVersion_withEditorNameDatenimport() {
Document doc = minimalDocument();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.backfillMissingVersions(List.of(doc));
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("Datenimport");
}
@Test
void backfill_usesDocumentCreatedAt_asSavedAt() {
LocalDateTime createdAt = LocalDateTime.of(2020, 3, 15, 10, 0);
Document doc = Document.builder()
.id(UUID.randomUUID()).title("T").createdAt(createdAt).build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.backfillMissingVersions(List.of(doc));
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getSavedAt()).isEqualTo(createdAt);
}
@Test
void backfill_setsChangedFieldsEmpty() {
Document doc = minimalDocument();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.backfillMissingVersions(List.of(doc));
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
}
@Test
void backfill_skipsDocuments_thatAlreadyHaveVersions() {
Document doc = minimalDocument();
DocumentVersion existing = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(doc.getId()).snapshot("{}")
.changedFields("[]").editorName("user").savedAt(LocalDateTime.now()).build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of(existing));
int count = versionService.backfillMissingVersions(List.of(doc));
verify(versionRepository, never()).save(any());
assertThat(count).isZero();
}
@Test
void backfill_returnsCountOfCreatedVersions() {
Document d1 = minimalDocument();
Document d2 = minimalDocument();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d1.getId())).thenReturn(List.of());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d2.getId())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
int count = versionService.backfillMissingVersions(List.of(d1, d2));
assertThat(count).isEqualTo(2);
}
// ─── helpers ──────────────────────────────────────────────────────────────
private void authenticateAs(String username) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(username, null, List.of()));
}
private AppUser stubUser(String username) {
return AppUser.builder().id(UUID.randomUUID()).username(username)
.firstName(null).lastName(null).build();
}
private Document minimalDocument() {
return Document.builder()
.id(UUID.randomUUID())
.title("Test")
.originalFilename("test.pdf")
.documentDate(LocalDate.of(1940, 5, 1))
.build();
}
}

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

@@ -0,0 +1,126 @@
package org.raddatz.familienarchiv.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
class PasswordResetServiceTest {
@Mock AppUserRepository userRepository;
@Mock PasswordResetTokenRepository tokenRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock JavaMailSender mailSender;
@InjectMocks PasswordResetService service;
private AppUser makeUser(String email) {
return AppUser.builder()
.id(UUID.randomUUID())
.username("testuser")
.email(email)
.password("hashed")
.build();
}
// ─── requestReset ─────────────────────────────────────────────────────────
@Test
void requestReset_savesTokenForKnownEmail() {
AppUser user = makeUser("user@example.com");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
service.requestReset("user@example.com", "http://localhost:3000");
verify(tokenRepository).save(argThat(t ->
t.getUser().equals(user)
&& t.getToken().length() == 64
&& !t.isUsed()));
}
@Test
void requestReset_doesNothingForUnknownEmail() {
when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty());
service.requestReset("ghost@example.com", "http://localhost:3000");
verify(tokenRepository, never()).save(any());
}
// ─── resetPassword ────────────────────────────────────────────────────────
@Test
void resetPassword_updatesPasswordForValidToken() {
AppUser user = makeUser("user@example.com");
PasswordResetToken token = PasswordResetToken.builder()
.id(UUID.randomUUID())
.token("validtoken123")
.user(user)
.expiresAt(LocalDateTime.now().plusHours(1))
.used(false)
.build();
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
when(passwordEncoder.encode("newpass")).thenReturn("hashed-newpass");
ResetPasswordRequest req = new ResetPasswordRequest();
req.setToken("validtoken123");
req.setNewPassword("newpass");
service.resetPassword(req);
verify(passwordEncoder).encode("newpass");
verify(userRepository).save(argThat(u -> u.getPassword().equals("hashed-newpass")));
assertThat(token.isUsed()).isTrue();
}
@Test
void resetPassword_throwsForExpiredToken() {
AppUser user = makeUser("user@example.com");
PasswordResetToken token = PasswordResetToken.builder()
.token("expiredtoken")
.user(user)
.expiresAt(LocalDateTime.now().minusMinutes(1))
.used(false)
.build();
when(tokenRepository.findByToken("expiredtoken")).thenReturn(Optional.of(token));
ResetPasswordRequest req = new ResetPasswordRequest();
req.setToken("expiredtoken");
req.setNewPassword("newpass");
assertThatThrownBy(() -> service.resetPassword(req))
.isInstanceOf(DomainException.class);
}
@Test
void resetPassword_throwsForUnknownToken() {
when(tokenRepository.findByToken("nosuchtoken")).thenReturn(Optional.empty());
ResetPasswordRequest req = new ResetPasswordRequest();
req.setToken("nosuchtoken");
req.setNewPassword("newpass");
assertThatThrownBy(() -> service.resetPassword(req))
.isInstanceOf(DomainException.class);
}
}

View File

@@ -58,6 +58,19 @@ services:
networks:
- archive-net
# --- Mail catcher: Mailpit (dev only) ---
# Catches all outgoing emails and displays them in a web UI.
# Access the inbox at http://localhost:${PORT_MAILPIT_UI} after starting the stack.
mailpit:
image: axllent/mailpit:latest
container_name: archive-mailpit
restart: unless-stopped
ports:
- "${PORT_MAILPIT_UI:-8025}:8025" # Web UI
- "${PORT_MAILPIT_SMTP:-1025}:1025" # SMTP
networks:
- archive-net
# --- Backend: Spring Boot ---
backend:
build:
@@ -74,6 +87,8 @@ services:
condition: service_healthy
minio:
condition: service_healthy
mailpit:
condition: service_started
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
@@ -83,6 +98,17 @@ services:
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
S3_REGION: us-east-1
SPRING_PROFILES_ACTIVE: dev,e2e
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
# Defaults to the local Mailpit catcher — override in .env for production SMTP
MAIL_HOST: ${MAIL_HOST:-mailpit}
MAIL_PORT: ${MAIL_PORT:-1025}
MAIL_USERNAME: ${MAIL_USERNAME:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@familienarchiv.local}
# Mailpit needs no auth or STARTTLS; production SMTP overrides these via .env
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-false}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
ports:
- "${PORT_BACKEND}:8080"
networks:

96
docs/mail.md Normal file
View File

@@ -0,0 +1,96 @@
# Mail configuration
Familienarchiv uses Spring Mail to send password reset emails. The mail sender is **optional** — if no SMTP host is configured, the feature degrades gracefully: a reset token is still created in the database, but no email is sent and a warning is logged.
## How it works in each environment
| Environment | Default behaviour |
|---|---|
| `docker-compose up` (dev) | Mailpit catches all emails — nothing leaves your machine |
| CI | No mail host set — emails are silently skipped, tokens tested via the `/api/auth/reset-token-for-test` endpoint |
| Production | Real SMTP server configured via environment variables |
---
## Development — Mailpit
[Mailpit](https://github.com/axllent/mailpit) is included in `docker-compose.yml` as a local mail catcher. It accepts SMTP connections from the backend and displays all caught emails in a web inbox. No credentials or external network access required.
**Start the stack as usual:**
```bash
docker-compose up -d
```
**Open the inbox:**
```
http://localhost:8025
```
All password reset emails appear here. Copy the reset link from the email body and open it in your browser to complete the flow end-to-end locally.
**Ports (configurable in `.env`):**
| Variable | Default | Purpose |
|---|---|---|
| `PORT_MAILPIT_UI` | `8025` | Mailpit web inbox |
| `PORT_MAILPIT_SMTP` | `1025` | SMTP port (used internally by the backend) |
---
## Production — real SMTP
To send real emails, set the following variables in your `.env` file (or as host environment variables). The `MAIL_HOST` variable is the switch — leaving it empty disables outgoing mail entirely.
```dotenv
# Required
APP_BASE_URL=https://your-domain.example.com # Base URL inserted into reset links
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=your-smtp-user
MAIL_PASSWORD=your-smtp-password
# Optional — adjust if your provider uses different settings
MAIL_SMTP_AUTH=true # default: false (Mailpit needs false)
MAIL_STARTTLS_ENABLE=true # default: false (Mailpit needs false)
APP_MAIL_FROM=noreply@your-domain.example.com
```
**Common provider settings:**
| Provider | Host | Port | Auth | STARTTLS |
|---|---|---|---|---|
| Gmail (App Password) | `smtp.gmail.com` | `587` | `true` | `true` |
| Mailgun | `smtp.mailgun.org` | `587` | `true` | `true` |
| Hetzner | `mail.your-server.de` | `587` | `true` | `true` |
| Self-hosted Postfix | your server IP/hostname | `587` | `true` | `true` |
> **Gmail note:** You must use an [App Password](https://support.google.com/accounts/answer/185833), not your regular account password. 2-Step Verification must be enabled on the account.
---
## Environment variable reference
All variables have safe defaults so the app starts without any mail configuration.
| Variable | Default (docker-compose) | Description |
|---|---|---|
| `MAIL_HOST` | `mailpit` | SMTP hostname. Empty string disables mail entirely. |
| `MAIL_PORT` | `1025` | SMTP port. |
| `MAIL_USERNAME` | *(empty)* | SMTP username. Leave empty if your server needs no auth. |
| `MAIL_PASSWORD` | *(empty)* | SMTP password. |
| `MAIL_SMTP_AUTH` | `false` | Enable SMTP authentication (`true` for real servers). |
| `MAIL_STARTTLS_ENABLE` | `false` | Enable STARTTLS (`true` for real servers on port 587). |
| `APP_MAIL_FROM` | `noreply@familienarchiv.local` | The `From:` address on outgoing emails. |
| `APP_BASE_URL` | `http://localhost:3000` | Base URL prepended to password reset links. |
---
## Disabling mail entirely
Set `MAIL_HOST` to an empty string. Spring Boot will not create a mail sender bean and no emails will be sent. Password reset tokens are still written to the database — useful if you want to test the reset flow via the API directly.
```dotenv
MAIL_HOST=
```

View File

@@ -5,7 +5,7 @@
"value": "de",
"domain": "localhost",
"path": "/",
"expires": 1808565334.192108,
"expires": 1808896929.897686,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
@@ -15,7 +15,7 @@
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
"domain": "localhost",
"path": "/",
"expires": 1774091734.449243,
"expires": 1774423330.233039,
"httpOnly": true,
"secure": false,
"sameSite": "Strict"

250
frontend/e2e/admin.spec.ts Normal file
View File

@@ -0,0 +1,250 @@
import { test, expect, type Browser } from '@playwright/test';
/**
* Admin panel E2E tests.
*
* Reads top-to-bottom as a complete admin journey:
* 1. Admin opens the dashboard and sees all three management tabs.
* 2. Admin creates a group for read-only access.
* 3. Admin creates a new user in that group.
* 4. Admin edits the user's profile.
* 5. Admin resets the user's password without knowing their current password.
* 6. The user can log in with the admin-set password.
* 7. Admin deletes the user.
* 8. Admin deletes the test group.
* 9. Admin renames a tag and renames it back.
*
* Steps 28 form a self-contained lifecycle: everything created in this suite
* is also deleted, leaving the database in its original state.
*/
// ── Dashboard ─────────────────────────────────────────────────────────────────
test.describe('Admin dashboard', () => {
test('admin navigates to /admin and sees the three management tabs', async ({ page }) => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByRole('button', { name: 'Benutzer', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Gruppen', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Schlagworte', exact: true })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-dashboard.png' });
});
});
// ── Group lifecycle ────────────────────────────────────────────────────────────
test.describe('Admin — group management', () => {
test('admin creates a new group "E2E Leser" with READ_ALL permission', async ({ page }) => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
// Switch to the Groups tab
await page.getByRole('button', { name: 'Gruppen', exact: true }).click();
await page.getByPlaceholder('Gruppenname (z.B. Editoren)').fill('E2E Leser');
// No permission checkboxes checked — READ_ALL is handled at application level
// (a group with no permissions gets read-only access by default in the UI)
await page.getByRole('button', { name: /Erstellen/i }).click();
await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-group-created.png' });
});
});
// ── User lifecycle ─────────────────────────────────────────────────────────────
test.describe('Admin — user lifecycle', () => {
test('admin creates user "e2e-testuser" and they appear in the user list', async ({ page }) => {
await page.goto('/admin/users/new');
await page.waitForSelector('[data-hydrated]');
await page.locator('input[name="username"]').fill('e2e-testuser');
await page.locator('input[name="password"]').fill('InitPass123!');
// Assign to the group we just created
const groupLabel = page.locator('label').filter({ hasText: 'E2E Leser' });
if ((await groupLabel.count()) > 0) {
await groupLabel.locator('input[type="checkbox"]').check();
}
await page.getByRole('button', { name: /Erstellen/i }).click();
// Redirected back to /admin — user appears in the table
await expect(page).toHaveURL('/admin');
await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-user-created.png' });
});
test('admin opens the edit page and updates the user first name', async ({ page }) => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
// Click the edit link for the test user
const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' });
await userRow.getByRole('link', { name: /Bearbeiten/i }).click();
await expect(page).toHaveURL(/\/admin\/users\/.+/);
await expect(
page.getByRole('heading', { name: /Benutzer bearbeiten: e2e-testuser/i })
).toBeVisible();
await page.locator('input[name="firstName"]').fill('E2E');
await page.locator('input[name="lastName"]').fill('Testuser');
await page.getByRole('button', { name: /Speichern/i }).click();
await expect(page.getByText('Änderungen gespeichert.')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-user-edited.png' });
});
test('admin sets a new password without entering the current password', async ({ page }) => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' });
await userRow.getByRole('link', { name: /Bearbeiten/i }).click();
// Password fields — no current password field on the admin edit form
await page.locator('input[name="newPassword"]').fill('AdminSet456!');
await page.locator('input[name="confirmPassword"]').fill('AdminSet456!');
await page.getByRole('button', { name: /Speichern/i }).click();
await expect(page.getByText('Änderungen gespeichert.')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-user-password-reset.png' });
});
test('the user can log in with the admin-set password', async ({ browser }) => {
// Open a completely separate browser context — no shared session cookies
const freshCtx = await (browser as Browser).newContext({
storageState: { cookies: [], origins: [] }
});
const freshPage = await freshCtx.newPage();
await freshPage.goto('/login');
await freshPage.getByLabel('Benutzername').fill('e2e-testuser');
await freshPage.getByLabel('Passwort').fill('AdminSet456!');
await freshPage.getByRole('button', { name: 'Anmelden' }).click();
await expect(freshPage).toHaveURL('/');
await freshPage.screenshot({ path: 'test-results/e2e/admin-user-login-new-password.png' });
await freshCtx.close();
});
test('admin deletes the test user and they disappear from the list', async ({ page }) => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' });
// The delete button triggers a window.confirm() dialog
page.once('dialog', (dialog) => dialog.accept());
await userRow.getByTitle('Benutzer löschen').click();
await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-user-deleted.png' });
});
});
// ── Group cleanup ──────────────────────────────────────────────────────────────
test.describe('Admin — group cleanup', () => {
test('admin deletes the "E2E Leser" group', async ({ page }) => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: 'Gruppen' }).click();
const groupRow = page.locator('tr').filter({ hasText: 'E2E Leser' });
page.once('dialog', (dialog) => dialog.accept());
await groupRow.getByTitle('Löschen').click();
await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-group-deleted.png' });
});
});
// ── Tag management ─────────────────────────────────────────────────────────────
test.describe('Admin — tag management', () => {
test('admin renames a tag and sees the change in the list', async ({ page }) => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: 'Schlagworte', exact: true }).click();
// Wait for the tags list to render after the tab switch
await page.waitForSelector('ul > li');
// Hover over the "Familie" row to reveal the opacity-0 action buttons
const familieRow = page
.locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie$/ }) });
await familieRow.hover();
await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
// After clicking edit, {#if editingTagId} replaces the span with a form —
// the familieRow filter no longer matches, so we find the input directly.
await page.locator('input[name="name"]').fill('Familie (E2E)');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Familie (E2E)')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' });
});
test('admin renames it back to restore the original name', async ({ page }) => {
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: 'Schlagworte', exact: true }).click();
await page.waitForSelector('ul > li');
const renamedRow = page
.locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) });
await renamedRow.hover();
await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
await page.locator('input[name="name"]').fill('Familie');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Familie')).toBeVisible();
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

@@ -48,8 +48,27 @@ test.describe('Authentication', () => {
await page.screenshot({ path: 'test-results/e2e/login-success.png' });
});
test('login establishes a session that authenticates API calls', async ({ page }) => {
// Guards against regressions where the session cookie is set but broken.
// The profile page calls /api/users/me server-side — if auth works end-to-end,
// it loads without redirecting to /login.
await login(page);
await page.goto('/profile');
await expect(page).toHaveURL('/profile');
await expect(page.getByRole('heading', { name: /Mein Profil/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/auth-session-valid.png' });
});
test('logout clears the session and redirects to /login', async ({ page }) => {
await login(page);
// Wait for hydration before interacting with the nav — onclick handlers are
// only wired up after SvelteKit finishes hydrating the page client-side.
await page.waitForSelector('[data-hydrated]');
// Logout is inside the user avatar dropdown — open it first.
// Wait for the dropdown button to be visible before clicking Abmelden,
// since the {#if userMenuOpen} block renders asynchronously in Svelte.
await page.locator('button[aria-haspopup="true"]').click();
await expect(page.getByRole('button', { name: 'Abmelden' })).toBeVisible();
await page.getByRole('button', { name: 'Abmelden' }).click();
await expect(page).toHaveURL(/\/login/);
// Confirm session is gone: navigating to / redirects back

View File

@@ -1,4 +1,9 @@
import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Document management E2E tests.
@@ -80,6 +85,41 @@ test.describe('New document', () => {
});
});
test.describe('Document creation', () => {
test('user fills in a title and lands on the new document detail page', async ({ page }) => {
await page.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E Testbrief');
await page.getByRole('button', { name: /Speichern/i }).click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-create.png' });
});
});
test.describe('Document editing', () => {
test('user opens an existing document, changes the title, and sees the update', async ({
page
}) => {
// Find the document created in the previous describe
await page.goto('/?q=E2E+Testbrief');
await page.waitForSelector('[data-hydrated]');
const docLink = page.getByRole('link', { name: 'E2E Testbrief' }).first();
const href = await docLink.getAttribute('href');
await page.goto(`${href}/edit`);
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)');
await page.getByRole('button', { name: /Speichern/i }).click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByText('E2E Testbrief (überarbeitet)')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit-save.png' });
});
});
test.describe('Document edit', () => {
test('renders the edit form with pre-filled data', async ({ page }) => {
// Navigate to home, find first document, go to its edit page
@@ -107,3 +147,358 @@ test.describe('Document edit', () => {
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
});
});
// ─── PDF Viewer ───────────────────────────────────────────────────────────────
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
test.describe('PDF viewer', () => {
let pdfDocHref: string;
let noFileDocHref: string;
test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
// Create a document with a PDF file.
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E PDF Viewer Test' }
});
if (!createRes.ok()) throw new Error(`Create document 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 PDF failed: ${uploadRes.status()}`);
pdfDocHref = `${baseURL}/documents/${doc.id}`;
// Create a document WITHOUT a file — used to verify no canvas is rendered.
const noFileRes = await request.post('/api/documents', {
multipart: { title: 'E2E No-File Test' }
});
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
const noFileDoc = await noFileRes.json();
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
});
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
page
}) => {
await page.goto(pdfDocHref);
await page.waitForSelector('[data-hydrated]');
// There must be NO iframe — we replaced it with PDF.js canvas rendering.
await expect(page.locator('iframe')).not.toBeAttached();
// At least one canvas element must be visible (one per rendered page).
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15000 });
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-canvas.png' });
});
test('page navigation controls are visible', async ({ page }) => {
await page.goto(pdfDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 15000 });
await expect(page.getByRole('button', { name: /prev|previous|zurück|vorige/i })).toBeVisible();
await expect(page.getByRole('button', { name: /next|weiter|nächste/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
});
test('document without a file has no canvas', async ({ page }) => {
// A document with no file attached must not render a PDF canvas.
await page.goto(noFileDocHref);
await page.waitForSelector('[data-hydrated]');
// No canvas — this document has no file
await expect(page.locator('canvas')).not.toBeAttached();
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
});
});
// ─── PDF Annotations (admin) ──────────────────────────────────────────────────
// Shared with the read-only user describe block below
let sharedAnnotationDocId: string;
test.describe('PDF annotations — admin', () => {
let annotationDocHref: string;
test.beforeAll(async ({ request }) => {
// Create a document with a PDF via API — much faster than UI automation.
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Annotations Test' }
});
if (!createRes.ok()) throw new Error(`Create document 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 PDF failed: ${uploadRes.status()}`);
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
annotationDocHref = `${baseURL}/documents/${doc.id}`;
sharedAnnotationDocId = doc.id;
});
test('admin user sees an active Annotieren button on a PDF', async ({ page }) => {
test.setTimeout(60_000);
await page.goto(annotationDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Admin has ANNOTATE_ALL — button must be enabled
const annotateBtn = page.getByRole('button', { name: /^annotieren$/i });
await expect(annotateBtn).toBeVisible();
await expect(annotateBtn).not.toBeDisabled();
await page.screenshot({ path: 'test-results/e2e/annotations-button-admin.png' });
});
test('admin can draw an annotation and it appears on the page', async ({ page }) => {
test.setTimeout(60_000);
await page.goto(annotationDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Enable annotate mode
await page.getByRole('button', { name: /^annotieren$/i }).click();
// Color picker must appear
await expect(page.getByLabel(/farbe/i)).toBeVisible();
// Draw on the annotation layer overlay
const annotationLayer = page.locator('[role="presentation"]').last();
const box = await annotationLayer.boundingBox();
if (!box) throw new Error('Annotation layer not found');
const startX = box.x + box.width * 0.3;
const startY = box.y + box.height * 0.3;
const endX = box.x + box.width * 0.55;
const endY = box.y + box.height * 0.55;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY);
await page.mouse.up();
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
await page.screenshot({ path: 'test-results/e2e/annotation-drawn.png' });
});
test('annotation persists after page reload', async ({ page }) => {
test.setTimeout(60_000);
await page.goto(annotationDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Annotation from the previous test must be loaded from the API
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
await page.screenshot({ path: 'test-results/e2e/annotation-persisted.png' });
});
test('admin can delete an annotation', async ({ page }) => {
test.setTimeout(60_000);
await page.goto(annotationDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Ensure annotation is visible before enabling annotate mode
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
// Enable annotate mode to show delete buttons
await page.getByRole('button', { name: /^annotieren$/i }).click();
const deleteBtn = page.getByRole('button', { name: /annotation löschen/i }).first();
await expect(deleteBtn).toBeVisible({ timeout: 8000 });
await deleteBtn.click();
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, {
timeout: 8000
});
await page.screenshot({ path: 'test-results/e2e/annotation-deleted.png' });
});
});
// ─── 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 does not see the Annotieren button', async ({ page }) => {
test.setTimeout(60_000);
await page.goto('/login');
await page.getByLabel('Benutzername').fill('reader');
await page.getByLabel('Passwort').fill('reader123');
await page.getByRole('button', { name: 'Anmelden' }).click();
await page.waitForURL('/');
// Navigate directly to the PDF document created by the admin beforeAll.
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
await page.waitForSelector('[data-hydrated]');
// 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 612 792]/Parent 2 0 R>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000054 00000 n
0000000105 00000 n
trailer
<</Size 4/Root 1 0 R>>
startxref
170
%%EOF

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

@@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Document edit history E2E tests.
* Creates its own test document (two versions) in beforeAll so these tests
* are fully independent of any other spec file.
*/
let docPath: string;
test.describe('Document history panel', () => {
test.beforeAll(async ({ browser }) => {
// Create a fresh browser context that uses the stored auth session
const context = await browser.newContext({
storageState: path.join(__dirname, '.auth/user.json'),
locale: 'de-DE'
});
const page = await context.newPage();
// 1. Create a new document
await page.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E History Test Dokument');
await page.getByRole('button', { name: /Speichern/i }).click();
// Wait for redirect to the new document's UUID-based URL (not /documents/new)
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
docPath = new URL(page.url()).pathname;
// 2. Edit the document to create a second version
await page.goto(`${docPath}/edit`);
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)');
await page.getByRole('button', { name: /Speichern/i }).click();
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
await context.close();
});
test('history section appears and shows two versions', async ({ page }) => {
await page.goto(docPath);
await page.waitForSelector('[data-hydrated]');
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
await expect(historyToggle).toBeVisible();
await historyToggle.click();
// Wait for versions to load (API call happens after panel opens)
const versionItems = page.locator('[data-testid="history-version"]');
await expect(versionItems).toHaveCount(2, { timeout: 10000 });
await page.screenshot({ path: 'test-results/e2e/history-versions-list.png' });
});
test('diff view highlights changed field after title edit', async ({ page }) => {
await page.goto(docPath);
await page.waitForSelector('[data-hydrated]');
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
await historyToggle.click();
// Wait for versions to load, then click the second one (the edit)
const versionItems = page.locator('[data-testid="history-version"]');
await expect(versionItems.nth(1)).toBeVisible({ timeout: 10000 });
await versionItems.nth(1).click();
const diffPanel = page.locator('[data-testid="history-diff"]');
await expect(diffPanel).toBeVisible();
await expect(diffPanel.getByText(/Titel/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/history-diff-title.png' });
});
test('compare mode lets user compare any two versions', async ({ page }) => {
await page.goto(docPath);
await page.waitForSelector('[data-hydrated]');
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
await historyToggle.click();
// Wait for versions to load before the compare button appears
await expect(page.locator('[data-testid="history-version"]').first()).toBeVisible({
timeout: 10000
});
const compareBtn = page.getByRole('button', { name: /Vergleichen/i });
await expect(compareBtn).toBeVisible();
await compareBtn.click();
const selectA = page.getByLabel(/Version A/i);
const selectB = page.getByLabel(/Version B/i);
await expect(selectA).toBeVisible();
await expect(selectB).toBeVisible();
// Select version 1 for A and version 2 for B
await selectA.selectOption({ index: 1 });
await selectB.selectOption({ index: 2 });
await page
.getByRole('button', { name: /Vergleichen/i })
.last()
.click();
const diffPanel = page.locator('[data-testid="history-diff"]');
await expect(diffPanel).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/history-compare-mode.png' });
});
});

View File

@@ -0,0 +1,113 @@
import { test, expect } from '@playwright/test';
/**
* Password-reset E2E tests.
*
* These tests run WITHOUT a stored session because they test unauthenticated flows.
*
* They rely on the "e2e" Spring profile being active in CI (see playwright.config.ts /
* docker-compose.e2e.yml). The profile exposes GET /api/auth/reset-token-for-test?email=
* so we can retrieve the generated token without a real mail server.
*/
test.use({ storageState: { cookies: [], origins: [] } });
// The backend is accessible directly for E2E helper calls (no SvelteKit proxy needed).
const BACKEND_URL = process.env.E2E_BACKEND_URL ?? 'http://localhost:8080';
async function getResetToken(email: string): Promise<string> {
const res = await fetch(
`${BACKEND_URL}/api/auth/reset-token-for-test?email=${encodeURIComponent(email)}`
);
if (!res.ok) throw new Error(`Could not retrieve reset token for ${email}: ${res.status}`);
return res.text();
}
test.describe('Password reset', () => {
test('forgot-password page is accessible without login', async ({ page }) => {
await page.goto('/forgot-password');
await expect(page).toHaveURL('/forgot-password');
await expect(page.getByRole('heading', { name: /Passwort vergessen/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/password-reset-form.png' });
});
test('forgot-password shows success banner for any email (prevents user enumeration)', async ({
page
}) => {
await page.goto('/forgot-password');
await page.getByLabel(/E-Mail/i).fill('nonexistent@example.com');
await page.getByRole('button', { name: /Link anfordern/i }).click();
// Always shows success — never reveals whether the email exists
await expect(page.locator('.bg-green-50')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/password-reset-success-banner.png' });
});
test('full password reset flow', async ({ page }) => {
const testEmail = process.env.E2E_EMAIL ?? 'admin@familyarchive.local';
const originalPassword = process.env.E2E_PASSWORD ?? 'admin123';
const newPassword = 'NewP@ssw0rd_E2E!';
// 1. Request reset
await page.goto('/forgot-password');
await page.getByLabel(/E-Mail/i).fill(testEmail);
await page.getByRole('button', { name: /Link anfordern/i }).click();
await expect(page.locator('.bg-green-50')).toBeVisible();
// 2. Fetch the token via the test helper endpoint
const token = await getResetToken(testEmail);
expect(token.length).toBeGreaterThan(0);
// 3. Open the reset-password page with the token
await page.goto(`/reset-password?token=${token}`);
await expect(page.getByRole('heading', { name: /Neues Passwort/i })).toBeVisible();
await page.getByLabel(/^Neues Passwort$/i).fill(newPassword);
await page.getByLabel(/Passwort bestätigen/i).fill(newPassword);
await page.getByRole('button', { name: /Passwort speichern/i }).click();
// 4. Success banner — then navigate to login
await expect(page.locator('.bg-green-50')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/password-reset-changed.png' });
await page.getByRole('link', { name: /Zurück zum Login/i }).click();
// 5. Log in with new password
await expect(page).toHaveURL(/\/login/);
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
await page.getByLabel('Passwort').fill(newPassword);
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL('/');
// 6. Restore original password via profile page
await page.goto('/profile');
await page.locator('input[name="currentPassword"]').fill(newPassword);
await page.locator('input[name="newPassword"]').fill(originalPassword);
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
// Profile page has two "Speichern" buttons — the password form is the last one
await page.locator('button[type="submit"]').last().click();
// After changing password, auth_token is stale → redirect to login
await expect(page).toHaveURL(/\/login/);
// 7. Log back in with original password to confirm restore worked
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
await page.getByLabel('Passwort').fill(originalPassword);
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL('/');
await page.screenshot({ path: 'test-results/e2e/password-reset-restored.png' });
});
test('reset-password page shows error for invalid token', async ({ page }) => {
await page.goto('/reset-password?token=invalidtoken000');
await page.getByLabel(/^Neues Passwort$/i).fill('somepassword');
await page.getByLabel(/Passwort bestätigen/i).fill('somepassword');
await page.getByRole('button', { name: /Passwort speichern/i }).click();
await expect(page.locator('.text-red-600')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/password-reset-invalid-token.png' });
});
test('reset-password page shows mismatch error when passwords differ', async ({ page }) => {
await page.goto('/reset-password?token=anytoken');
await page.getByLabel(/^Neues Passwort$/i).fill('password1');
await page.getByLabel(/Passwort bestätigen/i).fill('password2');
await page.getByRole('button', { name: /Passwort speichern/i }).click();
await expect(page.locator('.text-red-600')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/password-reset-mismatch.png' });
});
});

View File

@@ -1,4 +1,14 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
/**
* Permission E2E tests.
*
* Two describe blocks form the full story:
* 1. Admin user — can see all write controls.
* 2. Read-only user ("reader", seeded in DataInitializer with READ_ALL only) —
* can browse content but sees no write controls anywhere.
*/
test.describe('Write permissions — admin user', () => {
test('admin user sees Neues Dokument link on home page', async ({ page }) => {
@@ -29,3 +39,49 @@ test.describe('Write permissions — admin user', () => {
await expect(page.getByRole('button', { name: /Bearbeiten/i })).toBeVisible();
});
});
// ── Read-only user journey ─────────────────────────────────────────────────────
//
// The "reader" user is seeded by DataInitializer (e2e profile) with READ_ALL only.
// They can browse documents and persons but must not see any mutation controls.
test.describe('Read-only user — no write controls visible', () => {
// Fresh session — no shared admin cookies
test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach(async ({ page }) => {
await login(page, 'reader', 'reader123');
});
test('read-only user is redirected to home after login', async ({ page }) => {
await expect(page).toHaveURL('/');
await page.screenshot({ path: 'test-results/e2e/permissions-reader-home.png' });
});
test('home page does not show the "Neues Dokument" link', async ({ page }) => {
await expect(page.getByRole('link', { name: /Neues Dokument/i })).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc.png' });
});
test('persons page does not show the "Neue Person" link', async ({ page }) => {
await page.goto('/persons');
await expect(page.getByRole('link', { name: /Neue Person/i })).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-person.png' });
});
test('person detail page does not show the edit button', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
await firstPerson.click();
await page.waitForSelector('[data-hydrated]');
await expect(page.getByRole('button', { name: /Bearbeiten/i })).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-edit.png' });
});
test('navigating directly to /documents/new redirects away', async ({ page }) => {
await page.goto('/documents/new');
// Read-only user should not be able to access the new document form
await expect(page).not.toHaveURL('/documents/new');
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc-direct.png' });
});
});

View File

@@ -95,6 +95,21 @@ test.describe('New person', () => {
});
});
test.describe('Person creation', () => {
test('user fills in first and last name and lands on the new person detail page', async ({
page
}) => {
await page.goto('/persons/new');
await page.getByLabel('Vorname').fill('E2E');
await page.getByLabel('Nachname').fill('Testperson');
await page.getByRole('button', { name: /Erstellen/i }).click();
await expect(page).toHaveURL(/\/persons\/[^/]+$/);
await expect(page.getByRole('heading', { name: 'E2E Testperson' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-create.png' });
});
});
test.describe('Person detail — sort toggle', () => {
test('each section has its own sort toggle that works independently', async ({ page }) => {
await page.goto('/persons');
@@ -259,7 +274,10 @@ test.describe('Conversations — enhancements', () => {
const originalReceiverId = url.searchParams.get('receiverId')!;
await page.getByTestId('conv-swap-btn').click();
await page.waitForURL(/senderId=/);
// Wait for the URL to reflect the swapped IDs (not just any URL with senderId=)
await page.waitForURL(
(url) => new URL(url).searchParams.get('senderId') === originalReceiverId
);
const swappedUrl = new URL(page.url());
expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId);

View File

@@ -0,0 +1,106 @@
import { test, expect } from '@playwright/test';
/**
* Profile page E2E tests.
*
* Reads top-to-bottom as a single user journey:
* the logged-in admin opens their profile, updates their display name,
* tries a wrong password (sees an error), then successfully changes their
* password and logs back in with the new one.
*
* The password change test restores the original password at the end so the
* shared session remains valid for all subsequent test files.
*/
test.describe('Profile page', () => {
test('user opens their profile and sees the personal data and password sections', async ({
page
}) => {
await page.goto('/profile');
await expect(page.getByRole('heading', { name: /Mein Profil/i })).toBeVisible();
await expect(page.getByText('Persönliche Daten')).toBeVisible();
await expect(page.getByText('Passwort ändern')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/profile-view.png' });
});
test('user saves updated first and last name and sees confirmation', async ({ page }) => {
await page.goto('/profile');
await page.waitForSelector('[data-hydrated]');
await page.locator('input[name="firstName"]').fill('E2E');
await page.locator('input[name="lastName"]').fill('Admin');
// Two "Speichern" buttons exist — the first belongs to the profile form
await page
.locator('form[action*="updateProfile"]')
.getByRole('button', { name: /Speichern/i })
.click();
await expect(page.getByText('Gespeichert.')).toBeVisible();
// Nav avatar shows the new initials derived from firstName + lastName
await expect(page.locator('button[aria-haspopup="true"]')).toContainText('EA');
await page.screenshot({ path: 'test-results/e2e/profile-save.png' });
});
test('shows an error when the current password is wrong', async ({ page }) => {
await page.goto('/profile');
await page.waitForSelector('[data-hydrated]');
await page.locator('input[name="currentPassword"]').fill('definitely-wrong');
await page.locator('input[name="newPassword"]').fill('NewPass123!');
await page.locator('input[name="confirmPassword"]').fill('NewPass123!');
await page
.locator('form[action*="changePassword"]')
.getByRole('button', { name: /Speichern/i })
.click();
await expect(page.getByText('Das aktuelle Passwort ist falsch.')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/profile-wrong-password.png' });
});
test('user changes their password and can log in with the new one', async ({ page }) => {
await page.goto('/profile');
await page.waitForSelector('[data-hydrated]');
// ── Step 1: change to a temporary password ─────────────────────────────
await page.locator('input[name="currentPassword"]').fill('admin123');
await page.locator('input[name="newPassword"]').fill('TempAdmin456!');
await page.locator('input[name="confirmPassword"]').fill('TempAdmin456!');
await page
.locator('form[action*="changePassword"]')
.getByRole('button', { name: /Speichern/i })
.click();
// After the password changes, the auth_token cookie still carries the old
// credentials. use:enhance re-runs the page's load function, which calls
// the backend with the stale Basic Auth header → 401 → redirect to /login.
await expect(page).toHaveURL(/\/login/);
// ── Step 2: log in with the new password ───────────────────────────────
await page.getByLabel('Benutzername').fill('admin');
await page.getByLabel('Passwort').fill('TempAdmin456!');
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL('/');
await page.screenshot({ path: 'test-results/e2e/profile-password-changed.png' });
// ── Step 3: restore the original password so subsequent tests still work ─
await page.goto('/profile');
await page.waitForSelector('[data-hydrated]');
await page.locator('input[name="currentPassword"]').fill('TempAdmin456!');
await page.locator('input[name="newPassword"]').fill('admin123');
await page.locator('input[name="confirmPassword"]').fill('admin123');
await page
.locator('form[action*="changePassword"]')
.getByRole('button', { name: /Speichern/i })
.click();
// Redirected to /login again after credential rotation
await expect(page).toHaveURL(/\/login/);
// ── Step 4: log back in with the restored password ─────────────────────
await page.getByLabel('Benutzername').fill('admin');
await page.getByLabel('Passwort').fill('admin123');
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL('/');
});
});

View File

@@ -1,5 +1,8 @@
{
"$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.",
@@ -159,6 +162,14 @@
"admin_section_new_group": "Neue Gruppe anlegen",
"admin_group_name_placeholder": "Gruppenname (z.B. Editoren)",
"admin_user_delete_confirm": "Benutzer {username} wirklich löschen?",
"admin_btn_new_user": "Neuer Benutzer",
"admin_user_new_heading": "Neuen Benutzer anlegen",
"admin_user_edit_heading": "Benutzer bearbeiten: {username}",
"admin_user_created": "Benutzer wurde erstellt.",
"admin_user_updated": "Änderungen gespeichert.",
"admin_col_full_name": "Name",
"admin_label_new_password_optional": "Neues Passwort (optional)",
"admin_label_initial_password": "Passwort",
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
"doc_download_title": "Herunterladen",
"doc_tag_filter_title": "Nach {name} filtern",
@@ -194,5 +205,55 @@
"profile_password_mismatch": "Die neuen Passwörter stimmen nicht überein.",
"profile_saved": "Gespeichert.",
"profile_password_changed": "Passwort erfolgreich geändert.",
"user_profile_heading": "Profil von"
"user_profile_heading": "Profil von",
"error_invalid_reset_token": "Der Link ist ungültig oder abgelaufen.",
"forgot_password_heading": "Passwort vergessen",
"forgot_password_email_label": "E-Mail-Adresse",
"forgot_password_submit": "Link anfordern",
"forgot_password_success": "Falls ein Konto mit dieser E-Mail-Adresse existiert, erhalten Sie in Kürze eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.",
"forgot_password_back_to_login": "Zurück zum Login",
"reset_password_heading": "Neues Passwort festlegen",
"reset_password_label": "Neues Passwort",
"reset_password_confirm_label": "Passwort bestätigen",
"reset_password_submit": "Passwort speichern",
"reset_password_mismatch": "Die Passwörter stimmen nicht überein.",
"reset_password_success": "Ihr Passwort wurde erfolgreich geändert. Sie können sich jetzt anmelden.",
"login_forgot_password": "Passwort vergessen?",
"history_section_title": "Verlauf",
"history_loading": "Lade Verlauf…",
"history_empty": "Noch keine Versionen vorhanden.",
"history_version_label": "Version",
"history_compare_mode": "Vergleichen",
"history_compare_select_a": "Version A",
"history_compare_select_b": "Version B",
"history_compare_apply": "Vergleichen",
"history_diff_no_changes": "Keine Änderungen zwischen diesen Versionen.",
"history_field_title": "Titel",
"history_field_document_date": "Datum",
"history_field_location": "Ort",
"history_field_document_location": "Archivstandort",
"history_field_transcription": "Transkription",
"history_field_summary": "Zusammenfassung",
"history_field_sender": "Absender",
"history_field_receivers": "Empfänger",
"history_field_tags": "Schlagworte",
"admin_tab_system": "System",
"admin_system_backfill_heading": "Verlaufsdaten auffüllen",
"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.",
"comment_section_title": "Diskussion",
"comment_placeholder": "Kommentar schreiben…",
"comment_btn_post": "Senden",
"comment_btn_reply": "Antworten",
"comment_edited_label": "· bearbeitet",
"comment_panel_title": "Kommentare",
"comment_panel_close": "Schließen"
}

View File

@@ -1,5 +1,8 @@
{
"$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.",
@@ -159,6 +162,14 @@
"admin_section_new_group": "Create new group",
"admin_group_name_placeholder": "Group name (e.g. Editors)",
"admin_user_delete_confirm": "Really delete user {username}?",
"admin_btn_new_user": "New User",
"admin_user_new_heading": "Create new user",
"admin_user_edit_heading": "Edit user: {username}",
"admin_user_created": "User has been created.",
"admin_user_updated": "Changes saved.",
"admin_col_full_name": "Name",
"admin_label_new_password_optional": "New password (optional)",
"admin_label_initial_password": "Password",
"doc_file_error_preview": "Could not load preview.",
"doc_download_title": "Download",
"doc_tag_filter_title": "Filter by {name}",
@@ -194,5 +205,55 @@
"profile_password_mismatch": "The new passwords do not match.",
"profile_saved": "Saved.",
"profile_password_changed": "Password changed successfully.",
"user_profile_heading": "Profile of"
"user_profile_heading": "Profile of",
"error_invalid_reset_token": "The link is invalid or has expired.",
"forgot_password_heading": "Forgot password",
"forgot_password_email_label": "Email address",
"forgot_password_submit": "Request link",
"forgot_password_success": "If an account with this email address exists, you will shortly receive an email with a link to reset your password.",
"forgot_password_back_to_login": "Back to login",
"reset_password_heading": "Set new password",
"reset_password_label": "New password",
"reset_password_confirm_label": "Confirm password",
"reset_password_submit": "Save password",
"reset_password_mismatch": "The passwords do not match.",
"reset_password_success": "Your password has been changed successfully. You can now log in.",
"login_forgot_password": "Forgot password?",
"history_section_title": "History",
"history_loading": "Loading history…",
"history_empty": "No versions yet.",
"history_version_label": "Version",
"history_compare_mode": "Compare",
"history_compare_select_a": "Version A",
"history_compare_select_b": "Version B",
"history_compare_apply": "Compare",
"history_diff_no_changes": "No changes between these versions.",
"history_field_title": "Title",
"history_field_document_date": "Date",
"history_field_location": "Location",
"history_field_document_location": "Archive location",
"history_field_transcription": "Transcription",
"history_field_summary": "Summary",
"history_field_sender": "Sender",
"history_field_receivers": "Receivers",
"history_field_tags": "Tags",
"admin_tab_system": "System",
"admin_system_backfill_heading": "Backfill history data",
"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.",
"comment_section_title": "Discussion",
"comment_placeholder": "Write a comment…",
"comment_btn_post": "Send",
"comment_btn_reply": "Reply",
"comment_edited_label": "· edited",
"comment_panel_title": "Comments",
"comment_panel_close": "Close"
}

View File

@@ -1,5 +1,8 @@
{
"$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.",
@@ -159,6 +162,14 @@
"admin_section_new_group": "Crear nuevo grupo",
"admin_group_name_placeholder": "Nombre del grupo (p.ej. Editores)",
"admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?",
"admin_btn_new_user": "Nuevo usuario",
"admin_user_new_heading": "Crear nuevo usuario",
"admin_user_edit_heading": "Editar usuario: {username}",
"admin_user_created": "Usuario creado.",
"admin_user_updated": "Cambios guardados.",
"admin_col_full_name": "Nombre",
"admin_label_new_password_optional": "Nueva contraseña (opcional)",
"admin_label_initial_password": "Contraseña",
"doc_file_error_preview": "No se pudo cargar la vista previa.",
"doc_download_title": "Descargar",
"doc_tag_filter_title": "Filtrar por {name}",
@@ -194,5 +205,55 @@
"profile_password_mismatch": "Las nuevas contraseñas no coinciden.",
"profile_saved": "Guardado.",
"profile_password_changed": "Contraseña cambiada con éxito.",
"user_profile_heading": "Perfil de"
"user_profile_heading": "Perfil de",
"error_invalid_reset_token": "El enlace no es válido o ha expirado.",
"forgot_password_heading": "Contraseña olvidada",
"forgot_password_email_label": "Correo electrónico",
"forgot_password_submit": "Solicitar enlace",
"forgot_password_success": "Si existe una cuenta con esta dirección de correo electrónico, recibirá en breve un correo con un enlace para restablecer su contraseña.",
"forgot_password_back_to_login": "Volver al inicio de sesión",
"reset_password_heading": "Establecer nueva contraseña",
"reset_password_label": "Nueva contraseña",
"reset_password_confirm_label": "Confirmar contraseña",
"reset_password_submit": "Guardar contraseña",
"reset_password_mismatch": "Las contraseñas no coinciden.",
"reset_password_success": "Su contraseña ha sido cambiada con éxito. Ahora puede iniciar sesión.",
"login_forgot_password": "¿Olvidó su contraseña?",
"history_section_title": "Historial",
"history_loading": "Cargando historial…",
"history_empty": "Aún no hay versiones.",
"history_version_label": "Versión",
"history_compare_mode": "Comparar",
"history_compare_select_a": "Versión A",
"history_compare_select_b": "Versión B",
"history_compare_apply": "Comparar",
"history_diff_no_changes": "No hay cambios entre estas versiones.",
"history_field_title": "Título",
"history_field_document_date": "Fecha",
"history_field_location": "Lugar",
"history_field_document_location": "Ubicación en archivo",
"history_field_transcription": "Transcripción",
"history_field_summary": "Resumen",
"history_field_sender": "Remitente",
"history_field_receivers": "Destinatarios",
"history_field_tags": "Etiquetas",
"admin_tab_system": "Sistema",
"admin_system_backfill_heading": "Completar datos de historial",
"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.",
"comment_section_title": "Discusión",
"comment_placeholder": "Escribe un comentario…",
"comment_btn_post": "Enviar",
"comment_btn_reply": "Responder",
"comment_edited_label": "· editado",
"comment_panel_title": "Comentarios",
"comment_panel_close": "Cerrar"
}

View File

@@ -8,7 +8,9 @@
"name": "frontend",
"version": "0.0.1",
"dependencies": {
"openapi-fetch": "^0.13.5"
"diff": "^8.0.3",
"openapi-fetch": "^0.13.5",
"pdfjs-dist": "^5.5.207"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -21,6 +23,7 @@
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/diff": "^7.0.2",
"@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10",
"eslint": "^9.39.1",
@@ -883,6 +886,256 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.97",
"@napi-rs/canvas-darwin-arm64": "0.1.97",
"@napi-rs/canvas-darwin-x64": "0.1.97",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -1895,6 +2148,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/diff": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz",
"integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2780,6 +3040,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
@@ -3936,6 +4205,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -4111,6 +4387,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.5.207",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
"integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.95",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

@@ -20,7 +20,9 @@
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
},
"dependencies": {
"openapi-fetch": "^0.13.5"
"diff": "^8.0.3",
"openapi-fetch": "^0.13.5",
"pdfjs-dist": "^5.5.207"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -33,6 +35,7 @@
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/diff": "^7.0.2",
"@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10",
"eslint": "^9.39.1",

View File

@@ -12,7 +12,10 @@ export default defineConfig({
// The backend + DB + MinIO must be started separately (see README or CI workflow).
webServer: {
command: 'npm run dev -- --port 3000',
url: 'http://localhost:3000',
// Use the E2E_BASE_URL so that a pre-running server (e.g. the docker dev server
// on port 5173 during local development) is detected and reused without starting
// a new one. In CI the default is localhost:3000 where a fresh server is started.
url: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
reuseExistingServer: true,
timeout: 120_000
},

View File

@@ -5,7 +5,7 @@ import { env } from 'process';
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
import { detectLocale } from '$lib/server/locale';
const PUBLIC_PATHS = ['/login', '/logout'];
const PUBLIC_PATHS = ['/login', '/logout', '/forgot-password', '/reset-password'];
const handleLocaleDetection: Handle = ({ event, resolve }) => {
if (!event.cookies.get(cookieName)) {
@@ -71,6 +71,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
return fetch(request);
}
// Password reset endpoints are public — no auth header needed.
const PUBLIC_API_PATHS = ['/api/auth/forgot-password', '/api/auth/reset-password'];
if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) {
return fetch(request);
}
const token = event.cookies.get('auth_token');
if (!token) {

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import CommentThread from './CommentThread.svelte';
import { m } from '$lib/paraglide/messages.js';
type Props = {
documentId: string;
annotationId: string;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onClose: () => void;
onCountChange?: (count: number) => void;
};
let {
documentId,
annotationId,
canComment,
currentUserId,
canAdmin,
onClose,
onCountChange
}: Props = $props();
</script>
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
<div
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-brand-sand bg-white shadow-2xl sm:flex"
>
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
{m.comment_panel_title()}
</h3>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<CommentThread
documentId={documentId}
annotationId={annotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={onCountChange}
/>
</div>
</div>
<!-- Mobile modal (< sm): fixed full-screen with slide-up sheet -->
<div class="fixed inset-0 z-50 flex flex-col sm:hidden">
<!-- Semi-transparent backdrop -->
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
<!-- Slide-up panel -->
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-white shadow-2xl">
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
{m.comment_panel_title()}
</h3>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<CommentThread
documentId={documentId}
annotationId={annotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={onCountChange}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,218 @@
<script lang="ts">
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
};
type DrawRect = {
x: number;
y: number;
width: number;
height: number;
};
let {
annotations = [],
canAnnotate,
color,
onDraw,
onDelete,
commentCounts,
onAnnotationClick
}: {
annotations: Annotation[];
canAnnotate: boolean;
color: string;
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
onDelete: (id: string) => void;
commentCounts?: Record<string, number>;
onAnnotationClick?: (id: string) => void;
} = $props();
let drawStart = $state<{ x: number; y: number } | null>(null);
let drawRect = $state<DrawRect | null>(null);
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: number; y: number } {
const rect = element.getBoundingClientRect();
return {
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height
};
}
function handlePointerDown(event: PointerEvent) {
if (!canAnnotate) return;
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
const container = event.currentTarget as HTMLElement;
container.setPointerCapture(event.pointerId);
const coords = getNormalizedCoords(event, container);
drawStart = coords;
drawRect = { x: coords.x, y: coords.y, width: 0, height: 0 };
}
function handlePointerMove(event: PointerEvent) {
if (!canAnnotate || !drawStart) return;
const container = event.currentTarget as HTMLElement;
const coords = getNormalizedCoords(event, container);
const x = Math.min(drawStart.x, coords.x);
const y = Math.min(drawStart.y, coords.y);
const width = Math.abs(coords.x - drawStart.x);
const height = Math.abs(coords.y - drawStart.y);
drawRect = { x, y, width, height };
}
function handlePointerUp(event: PointerEvent) {
if (!canAnnotate || !drawStart || !drawRect) return;
const container = event.currentTarget as HTMLElement;
const coords = getNormalizedCoords(event, container);
const x = Math.min(drawStart.x, coords.x);
const y = Math.min(drawStart.y, coords.y);
const width = Math.abs(coords.x - drawStart.x);
const height = Math.abs(coords.y - drawStart.y);
if (width > 0.01 && height > 0.01) {
onDraw({ x, y, width, height });
}
drawStart = null;
drawRect = null;
}
let hoveredId = $state<string | null>(null);
const containerStyle = $derived(
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}`
);
</script>
<div
style={containerStyle}
role="presentation"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
{#each annotations as annotation (annotation.id)}
<div
data-testid="annotation-{annotation.id}"
data-annotation
role="button"
tabindex="0"
aria-label="Kommentare anzeigen"
onclick={() => onAnnotationClick?.(annotation.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
onmouseenter={() => (hoveredId = annotation.id)}
onmouseleave={() => (hoveredId = null)}
style="
position: absolute;
left: {annotation.x * 100}%;
top: {annotation.y * 100}%;
width: {annotation.width * 100}%;
height: {annotation.height * 100}%;
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)};
box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
pointer-events: auto;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
"
>
{#if canAnnotate}
<button
aria-label="Annotation löschen"
onclick={(e) => {
e.stopPropagation();
const count = commentCounts?.[annotation.id] ?? 0;
if (count > 0) {
const msg =
count === 1
? 'Diese Annotation hat 1 Kommentar. Beim Löschen wird er ebenfalls entfernt. Fortfahren?'
: `Diese Annotation hat ${count} Kommentare. Beim Löschen werden sie ebenfalls entfernt. Fortfahren?`;
if (!window.confirm(msg)) return;
}
onDelete(annotation.id);
}}
style="
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #ef4444;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
padding: 0;
pointer-events: auto;
">×</button
>
{/if}
{#if (commentCounts?.[annotation.id] ?? 0) > 0}
<div
style="
position: absolute;
bottom: -10px;
right: -10px;
background-color: #002850;
color: white;
font-size: 11px;
font-family: sans-serif;
font-weight: 600;
padding: 2px 6px;
border-radius: 999px;
min-width: 20px;
text-align: center;
white-space: nowrap;
pointer-events: none;
line-height: 18px;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
"
>
{commentCounts?.[annotation.id]}
</div>
{/if}
</div>
{/each}
{#if drawRect && drawRect.width > 0}
<div
style="
position: absolute;
left: {drawRect.x * 100}%;
top: {drawRect.y * 100}%;
width: {drawRect.width * 100}%;
height: {drawRect.height * 100}%;
border: 2px dashed {color};
opacity: 0.3;
pointer-events: none;
"
></div>
{/if}
</div>

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationLayer from './AnnotationLayer.svelte';
afterEach(cleanup);
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
};
function makeAnnotation(id = 'ann-1'): Annotation {
return {
id,
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.2,
color: '#ff0000',
createdAt: new Date().toISOString()
};
}
describe('AnnotationLayer', () => {
it('renders a colored element for each annotation', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
canAnnotate: false,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
});
it('shows a delete button for each annotation when canAnnotate is true', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: true,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
});
await expect
.element(page.getByRole('button', { name: /annotation löschen/i }))
.toBeInTheDocument();
});
it('does not show delete buttons when canAnnotate is false', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: false,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
});
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
});
});

View File

@@ -0,0 +1,394 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
type CommentReply = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
};
type Comment = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
replies: CommentReply[];
};
type Props = {
documentId: string;
annotationId?: string | null;
initialComments?: Comment[];
loadOnMount?: boolean;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onCountChange?: (count: number) => void;
};
let {
documentId,
annotationId = null,
initialComments = [],
loadOnMount = false,
canComment,
currentUserId,
canAdmin,
onCountChange
}: Props = $props();
let comments: Comment[] = $state(untrack(() => [...initialComments]));
let newText: string = $state('');
let replyingTo: string | null = $state(null);
let replyText: string = $state('');
let editingId: string | null = $state(null);
let editText: string = $state('');
let posting: boolean = $state(false);
const commentsBase = $derived(
annotationId
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
: `/api/documents/${documentId}/comments`
);
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'gerade eben';
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? '' : 'n'}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`;
const days = Math.floor(hours / 24);
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
}
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
return c.updatedAt > c.createdAt;
}
function canModify(c: { authorId: string | null }): boolean {
return (currentUserId != null && c.authorId === currentUserId) || canAdmin;
}
async function reload() {
try {
const res = await fetch(commentsBase);
if (res.ok) {
comments = await res.json();
const total = comments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
} catch {
/* ignore */
}
}
async function postComment() {
const text = newText.trim();
if (!text || posting) return;
posting = true;
try {
const res = await fetch(commentsBase, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
});
if (res.ok) {
newText = '';
await reload();
}
} finally {
posting = false;
}
}
async function postReply(threadId: string) {
const text = replyText.trim();
if (!text || posting) return;
posting = true;
try {
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
});
if (res.ok) {
replyText = '';
replyingTo = null;
await reload();
}
} finally {
posting = false;
}
}
async function saveEdit(commentId: string) {
const text = editText.trim();
if (!text || posting) return;
posting = true;
try {
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
});
if (res.ok) {
editingId = null;
await reload();
}
} finally {
posting = false;
}
}
async function deleteComment(commentId: string) {
if (posting) return;
posting = true;
try {
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'DELETE'
});
if (res.ok) {
await reload();
}
} finally {
posting = false;
}
}
function startEdit(comment: Comment | CommentReply) {
editingId = comment.id;
editText = comment.content;
}
function cancelEdit() {
editingId = null;
editText = '';
}
function startReply(threadId: string) {
replyingTo = threadId;
replyText = '';
}
function cancelReply() {
replyingTo = null;
replyText = '';
}
onMount(() => {
if (loadOnMount) {
reload();
}
});
</script>
<div class="space-y-4">
{#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-brand-sand pt-4' : ''}>
<!-- Root comment -->
<div>
{#if editingId === thread.id}
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
rows={3}
bind:value={editText}
></textarea>
<div class="flex items-center gap-3">
<button
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(thread.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
onclick={cancelEdit}
>
{m.btn_cancel()}
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-sans text-xs font-semibold text-brand-navy"
>{thread.authorName}</span
>
<span class="font-sans text-xs text-gray-400">{timeAgo(thread.createdAt)}</span>
{#if wasEdited(thread)}
<span class="font-sans text-xs text-gray-400">
{m.comment_edited_label()}
{timeAgo(thread.updatedAt)}
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{thread.content}</p>
</div>
{#if canModify(thread)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
onclick={() => startEdit(thread)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
onclick={() => deleteComment(thread.id)}
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
<!-- Reply button on root comment only if there are no replies -->
{#if thread.replies.length === 0 && canComment}
<div class="mt-1">
<button
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
onclick={() => startReply(thread.id)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
</div>
<!-- Replies -->
{#each thread.replies as reply, ri (reply.id)}
<div class="mt-3 ml-6 border-l-2 border-brand-sand pl-4">
{#if editingId === reply.id}
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
rows={3}
bind:value={editText}
></textarea>
<div class="flex items-center gap-3">
<button
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(reply.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
onclick={cancelEdit}
>
{m.btn_cancel()}
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-sans text-xs font-semibold text-brand-navy"
>{reply.authorName}</span
>
<span class="font-sans text-xs text-gray-400">{timeAgo(reply.createdAt)}</span>
{#if wasEdited(reply)}
<span class="font-sans text-xs text-gray-400">
{m.comment_edited_label()}
{timeAgo(reply.updatedAt)}
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{reply.content}</p>
</div>
{#if canModify(reply)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
onclick={() => startEdit(reply)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
onclick={() => deleteComment(reply.id)}
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
<!-- Reply button only on the last reply -->
{#if ri === thread.replies.length - 1 && canComment}
<div class="mt-1">
<button
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
onclick={() => startReply(thread.id)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
</div>
{/each}
<!-- Reply textarea (shown when replyingTo === thread.id) -->
{#if replyingTo === thread.id}
<div class="mt-3 ml-6 flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
rows={3}
placeholder={m.comment_placeholder()}
bind:value={replyText}
></textarea>
<div class="flex items-center gap-3">
<button
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
disabled={posting}
onclick={() => postReply(thread.id)}
>
{m.comment_btn_post()}
</button>
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
onclick={cancelReply}
>
{m.btn_cancel()}
</button>
</div>
</div>
{/if}
</div>
{/each}
<!-- New top-level comment textarea -->
{#if canComment}
<div class={comments.length > 0 ? 'border-t border-brand-sand pt-4' : ''}>
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
rows={3}
placeholder={m.comment_placeholder()}
bind:value={newText}
></textarea>
<div>
<button
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
disabled={posting || !newText.trim()}
onclick={postComment}
>
{m.comment_btn_post()}
</button>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { text, maxLines = 10 }: { text: string; maxLines?: number } = $props();
let expanded = $state(false);
let el = $state<HTMLElement | undefined>(undefined);
let isClamped = $state(false);
$effect(() => {
if (el && !expanded) {
isClamped = el.scrollHeight > el.clientHeight;
}
});
</script>
<div>
<div
bind:this={el}
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
>
{text}
</div>
{#if isClamped || expanded}
<button
onclick={() => (expanded = !expanded)}
class="mt-2 font-sans text-xs text-gray-400 transition hover:text-brand-navy"
>
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
</button>
{/if}
</div>

View File

@@ -0,0 +1,501 @@
<script lang="ts">
import { onMount } from 'svelte';
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,
documentId = '',
canAnnotate = false,
canComment,
currentUserId,
canAdmin,
documentFileHash
}: {
url: string;
documentId?: string;
canAnnotate?: boolean;
canComment?: boolean;
currentUserId?: string | null;
canAdmin?: boolean;
documentFileHash?: string | null;
} = $props();
let pdfDoc = $state<PDFDocumentProxy | null>(null);
let currentPage = $state(1);
let totalPages = $state(0);
let scale = $state(1.5);
let loading = $state(false);
let error = $state<string | null>(null);
// Canvas and text layer container refs — bound via bind:this, not reactive state
let canvasEl = $state<HTMLCanvasElement | null>(null);
let textLayerEl = $state<HTMLDivElement | null>(null);
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
let renderTask: RenderTask | null = null;
let textLayerInstance: { cancel: () => void } | null = null;
// Holds the dynamically-loaded pdfjs module (browser-only)
// Not $state — we use pdfjsReady as the reactive trigger instead
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
let pdfjsReady = $state(false);
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
fileHash?: string | null;
};
let annotations = $state<Annotation[]>([]);
let annotateMode = $state(false);
let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>();
let activeAnnotationId = $state<string | null>(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([
import('pdfjs-dist'),
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
]);
lib.GlobalWorkerOptions.workerSrc = workerUrl;
pdfjsLib = lib;
pdfjsReady = true;
});
async function loadDocument(src: string) {
if (!pdfjsLib) return;
loading = true;
error = null;
pdfDoc = null;
currentPage = 1;
totalPages = 0;
try {
const loadingTask = pdfjsLib.getDocument(src);
const doc = await loadingTask.promise;
pdfDoc = doc;
totalPages = doc.numPages;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load PDF';
} finally {
loading = false;
}
}
async function renderPage(doc: PDFDocumentProxy, pageNum: number) {
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
// Cancel any in-flight render
if (renderTask) {
renderTask.cancel();
renderTask = null;
}
if (textLayerInstance) {
textLayerInstance.cancel();
textLayerInstance = null;
}
let page: PDFPageProxy;
try {
page = await doc.getPage(pageNum);
} catch {
return;
}
const dpr = window.devicePixelRatio || 1;
const viewport = page.getViewport({ scale: scale * dpr });
const canvas = canvasEl;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
const task = page.render({ canvas, canvasContext: ctx, viewport });
renderTask = task;
try {
await task.promise;
} catch (e: unknown) {
if (
typeof e === 'object' &&
e !== null &&
'name' in e &&
(e as { name: string }).name === 'RenderingCancelledException'
)
return;
return;
}
renderTask = null;
// Text layer
const textDiv = textLayerEl;
textDiv.innerHTML = '';
textDiv.style.width = `${viewport.width / dpr}px`;
textDiv.style.height = `${viewport.height / dpr}px`;
const tl = new pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: textDiv,
viewport
});
textLayerInstance = tl;
try {
await tl.render();
} catch {
// cancelled
}
}
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
for (const n of neighbors) {
try {
await doc.getPage(n);
} catch {
// ignore
}
}
}
async function loadCommentCounts(docId: string, anns: Annotation[]) {
await Promise.all(
anns.map(async (a) => {
try {
const res = await fetch(`/api/documents/${docId}/annotations/${a.id}/comments`);
if (res.ok) {
const threads = (await res.json()) as Array<{ replies: unknown[] }>;
const total = threads.reduce((sum, t) => sum + 1 + t.replies.length, 0);
commentCounts.set(a.id, total);
}
} catch {
// ignore
}
})
);
}
async function loadAnnotations(docId: string) {
if (!docId) return;
try {
const res = await fetch(`/api/documents/${docId}/annotations`);
if (res.ok) {
annotations = await res.json();
await loadCommentCounts(docId, annotations);
}
} catch {
// ignore
}
}
async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
if (!documentId) return;
try {
const res = await fetch(`/api/documents/${documentId}/annotations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pageNumber: currentPage,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: annotateColor
})
});
if (res.ok) {
const created: Annotation = await res.json();
annotations = [...annotations, created];
activeAnnotationId = created.id;
}
} catch {
// ignore
}
}
async function handleAnnotationDelete(annotationId: string) {
if (!documentId) return;
try {
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (res.ok) {
annotations = annotations.filter((a) => a.id !== annotationId);
}
} catch {
// ignore
}
}
$effect(() => {
if (pdfjsReady && url) {
loadDocument(url);
}
});
$effect(() => {
// Read scale synchronously so Svelte tracks it as a dependency.
// Without this, zoom changes don't re-trigger the effect because
// scale is only read inside the async renderPage call.
if (pdfDoc && currentPage && scale > 0) {
renderPage(pdfDoc, currentPage).then(() => {
if (pdfDoc) prerender(pdfDoc, currentPage);
});
}
});
$effect(() => {
if (documentId) {
loadAnnotations(documentId);
}
});
function prevPage() {
if (currentPage > 1) currentPage -= 1;
}
function nextPage() {
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
}
function zoomIn() {
scale += 0.25;
}
function zoomOut() {
if (scale > 0.5) scale -= 0.25;
}
</script>
{#if !url}
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400">
<p class="font-sans text-sm">Keine Datei vorhanden</p>
</div>
{:else if error}
<div
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
>
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
class="font-sans text-xs text-brand-mint underline hover:opacity-80"
>
Direkt öffnen
</a>
</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"
>
<!-- Page navigation -->
<div class="flex items-center gap-2">
<button
onclick={prevPage}
disabled={currentPage <= 1}
aria-label="Zurück"
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
{#if totalPages > 0}
<span class="font-sans text-xs text-gray-300 tabular-nums">
{currentPage} / {totalPages}
</span>
{/if}
<button
onclick={nextPage}
disabled={!pdfDoc || currentPage >= totalPages}
aria-label="Weiter"
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Zoom controls -->
<div class="flex items-center gap-1">
<button
onclick={zoomOut}
aria-label="Verkleinern"
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" /><path
stroke-linecap="round"
d="M21 21l-4.35-4.35M8 11h6"
/>
</svg>
</button>
<button
onclick={zoomIn}
aria-label="Vergrößern"
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" /><path
stroke-linecap="round"
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
/>
</svg>
</button>
</div>
<!-- Annotate controls -->
{#if canAnnotate}
<div class="flex items-center gap-1">
<button
onclick={() => (annotateMode = !annotateMode)}
aria-label={annotateMode ? 'Annotieren beenden' : 'Annotieren'}
class="rounded px-2 py-1 font-sans text-xs text-gray-300 transition hover:bg-white/10 {annotateMode ? 'bg-white/20' : ''}"
>
{annotateMode ? 'Fertig' : 'Annotieren'}
</button>
{#if annotateMode}
<input
type="color"
bind:value={annotateColor}
aria-label="Farbe wählen"
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
title="Farbe wählen"
/>
{/if}
</div>
{:else}
<button
disabled
title="Sie benötigen die Berechtigung ANNOTATE_ALL zum Annotieren"
class="cursor-not-allowed rounded px-2 py-1 font-sans text-xs text-gray-500"
aria-label="Annotieren (keine Berechtigung)"
>
Annotieren
</button>
{/if}
</div>
<!-- PDF canvas area -->
<div class="relative flex-1 overflow-auto">
{#if loading}
<div class="flex h-full items-center justify-center">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
></div>
</div>
{:else}
<div class="flex min-h-full items-start justify-center p-4">
<div
class="pdf-page relative shadow-xl"
data-page-number={currentPage}
style="position: relative"
>
<canvas bind:this={canvasEl}></canvas>
<div
bind:this={textLayerEl}
class="textLayer"
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
></div>
<AnnotationLayer
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode}
color={annotateColor}
onDraw={handleAnnotationDraw}
onDelete={handleAnnotationDelete}
commentCounts={Object.fromEntries(commentCounts)}
onAnnotationClick={(id) => (activeAnnotationId = id)}
/>
</div>
</div>
{/if}
</div>
{#key activeAnnotationId}
{#if activeAnnotationId}
<AnnotationCommentPanel
documentId={documentId}
annotationId={activeAnnotationId}
canComment={canComment ?? false}
currentUserId={currentUserId ?? null}
canAdmin={canAdmin ?? false}
onClose={() => (activeAnnotationId = null)}
onCountChange={(count) => {
if (activeAnnotationId) commentCounts.set(activeAnnotationId, count);
}}
/>
{/if}
{/key}
</div>
{/if}

View File

@@ -0,0 +1,53 @@
import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need
// a real browser PDF engine. The interesting behaviour under test here is the
// component's own UI logic (controls, page counter), not pdfjs internals.
vi.mock('pdfjs-dist', () => {
function TextLayerMock() {}
TextLayerMock.prototype.render = () => Promise.resolve();
TextLayerMock.prototype.cancel = () => {};
return {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve({
numPages: 2,
getPage: vi.fn().mockResolvedValue({
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
})
})
}),
TextLayer: TextLayerMock
};
});
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
import PdfViewer from './PdfViewer.svelte';
afterEach(cleanup);
describe('PdfViewer', () => {
it('shows previous and next page navigation buttons', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' });
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
});
it('shows zoom controls', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' });
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument();
});
it('displays the page counter once the PDF has loaded', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' });
// Mock resolves synchronously, so "1 / 2" should appear quickly
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
});
});

View File

@@ -13,6 +13,10 @@ export type ErrorCode =
| 'EMAIL_ALREADY_IN_USE'
| 'WRONG_CURRENT_PASSWORD'
| 'IMPORT_ALREADY_RUNNING'
| 'INVALID_RESET_TOKEN'
| 'ANNOTATION_NOT_FOUND'
| 'ANNOTATION_OVERLAP'
| 'COMMENT_NOT_FOUND'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
@@ -58,6 +62,14 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_wrong_current_password();
case 'IMPORT_ALREADY_RUNNING':
return m.error_import_already_running();
case 'INVALID_RESET_TOKEN':
return m.error_invalid_reset_token();
case 'ANNOTATION_NOT_FOUND':
return m.error_annotation_not_found();
case 'ANNOTATION_OVERLAP':
return m.error_annotation_overlap();
case 'COMMENT_NOT_FOUND':
return m.error_comment_not_found();
case 'UNAUTHORIZED':
return m.error_unauthorized();
case 'FORBIDDEN':

View File

@@ -4,6 +4,22 @@
*/
export interface paths {
"/api/users/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getUser"];
put: operations["adminUpdateUser"];
post?: never;
delete: operations["deleteUser"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/me": {
parameters: {
query?: never;
@@ -164,6 +180,118 @@ 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;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["resetPassword"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/forgot-password": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["forgotPassword"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/trigger-import": {
parameters: {
query?: never;
@@ -180,6 +308,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/backfill-versions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["backfillVersions"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/groups/{id}": {
parameters: {
query?: never;
@@ -196,20 +340,20 @@ export interface paths {
patch: operations["updateGroup"];
trace?: never;
};
"/api/users/{id}": {
"/api/documents/{documentId}/comments/{commentId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getUser"];
get?: never;
put?: never;
post?: never;
delete: operations["deleteUser"];
delete: operations["deleteComment"];
options?: never;
head?: never;
patch?: never;
patch: operations["editComment"];
trace?: never;
};
"/api/tags": {
@@ -276,6 +420,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/{id}/versions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getVersions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{id}/versions/{versionId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getVersion"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{id}/file": {
parameters: {
query?: never;
@@ -340,17 +516,35 @@ 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 {
schemas: {
UpdateProfileDTO: {
AdminUpdateUserRequest: {
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email?: string;
contact?: string;
newPassword?: string;
groupIds?: string[];
};
AppUser: {
/** Format: uuid */
@@ -374,6 +568,14 @@ export interface components {
name: string;
permissions: string[];
};
UpdateProfileDTO: {
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email?: string;
contact?: string;
};
Tag: {
/** Format: uuid */
id: string;
@@ -420,6 +622,7 @@ export interface components {
title: string;
filePath?: string;
contentType?: string;
fileHash?: string;
originalFilename: string;
/** @enum {string} */
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
@@ -444,6 +647,11 @@ export interface components {
email?: string;
initialPassword?: string;
groupIds?: string[];
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
contact?: string;
};
ChangePasswordDTO: {
currentPassword?: string;
@@ -453,6 +661,70 @@ 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;
};
ForgotPasswordRequest: {
email?: string;
};
ImportStatus: {
/** @enum {string} */
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
@@ -462,6 +734,31 @@ export interface components {
/** Format: date-time */
startedAt?: string;
};
BackfillResult: {
/** Format: int32 */
count: number;
};
DocumentVersionSummary: {
/** Format: uuid */
id: string;
/** Format: date-time */
savedAt: string;
editorName: string;
changedFields: string[];
};
DocumentVersion: {
/** Format: uuid */
id: string;
/** Format: uuid */
documentId: string;
/** Format: date-time */
savedAt: string;
/** Format: uuid */
editorId?: string;
editorName: string;
snapshot: string;
changedFields: string;
};
};
responses: never;
parameters: never;
@@ -471,6 +768,74 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
getUser: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
adminUpdateUser: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AdminUpdateUserRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
deleteUser: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getCurrentUser: {
parameters: {
query?: never;
@@ -867,6 +1232,249 @@ 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;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ResetPasswordRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
forgotPassword: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ForgotPasswordRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
triggerMassImport: {
parameters: {
query?: never;
@@ -887,6 +1495,26 @@ export interface operations {
};
};
};
backfillVersions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BackfillResult"];
};
};
};
};
deleteGroup: {
parameters: {
query?: never;
@@ -933,16 +1561,42 @@ export interface operations {
};
};
};
getUser: {
deleteComment: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
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: {
@@ -950,31 +1604,11 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
deleteUser: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
searchTags: {
parameters: {
query?: {
@@ -1065,6 +1699,51 @@ export interface operations {
};
};
};
getVersions: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentVersionSummary"][];
};
};
};
};
getVersion: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
versionId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentVersion"];
};
};
};
};
getDocumentFile: {
parameters: {
query?: never;
@@ -1160,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

@@ -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

@@ -46,7 +46,7 @@ function clickOutside(node: HTMLElement) {
</script>
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
{#if !page.url.pathname.startsWith('/login')}
{#if !['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))}
<header class="sticky top-0 z-50 border-b border-gray-100 bg-white">
<!-- De Gruyter Brill purple accent strip -->
<div class="h-1 bg-brand-purple"></div>

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

@@ -35,21 +35,6 @@ export async function load({ fetch, locals }) {
}
export const actions = {
createUser: async ({ request, fetch }) => {
const data = await request.formData();
const api = createApiClient(fetch);
const result = await api.POST('/api/users', {
body: {
username: data.get('username') as string,
initialPassword: data.get('password') as string,
groupIds: data.getAll('groupIds') as string[]
}
});
return toActionResult(result);
},
deleteUser: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;

View File

@@ -8,8 +8,11 @@ let { data, form } = $props();
let activeTab = $state('users');
let editingTagId: string | null = $state(null);
let editingTagName = $state('');
let editingUserId: string | null = $state(null);
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'];
@@ -23,14 +26,6 @@ function cancelEditTag() {
editingTagName = '';
}
function startEditUser(id: string) {
editingUserId = id;
}
function cancelEditUser() {
editingUserId = null;
}
function startEditGroup(id: string) {
editingGroupId = id;
}
@@ -38,6 +33,34 @@ function startEditGroup(id: string) {
function cancelEditGroup() {
editingGroupId = null;
}
async function backfillVersions() {
backfillLoading = true;
backfillResult = null;
try {
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
if (res.ok) {
const data = await res.json();
backfillResult = data.count;
}
} finally {
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">
@@ -67,6 +90,13 @@ function cancelEditGroup() {
: 'text-gray-500 hover:text-brand-navy'}"
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
>
<button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
'system'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button
>
</div>
</div>
@@ -80,6 +110,20 @@ function cancelEditGroup() {
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
<div class="flex items-center justify-between border-b border-gray-100 p-6">
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
<a
href="/admin/users/new"
class="inline-flex items-center gap-1 rounded-sm bg-brand-navy px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{m.admin_btn_new_user()}
</a>
</div>
<table class="min-w-full divide-y divide-gray-200">
@@ -88,200 +132,89 @@ function cancelEditGroup() {
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_login()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_full_name()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_groups()}</th
>
{#if editingUserId}
<th
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_password()}</th
>
{/if}
<th
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_actions()}</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.users as user (user.id)}
<tr class="group/row hover:bg-gray-50">
{#if editingUserId === user.id}
<!-- === EDIT MODE === -->
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{user.username}
<input
type="hidden"
name="username"
value={user.username}
form="edit-form-{user.id}"
/>
</td>
<td class="px-6 py-4 text-sm">
<select
name="groupIds"
multiple
form="edit-form-{user.id}"
class="block min-h-[80px] w-full rounded border-brand-mint p-1 text-xs"
>
{#each data.groups as group (group.id)}
<option
value={group.id}
selected={user.groups.some((g: { id: string }) => g.id === group.id)}
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
{user.username}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{#if user.firstName || user.lastName}
{user.firstName ?? ''} {user.lastName ?? ''}
{:else}
<span class="text-gray-300 italic"></span>
{/if}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#if user.groups && user.groups.length > 0}
{#each user.groups as group (group.id)}
<span
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
>
{group.name}
</option>
</span>
{/each}
</select>
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint()}</p>
</td>
<td class="px-6 py-4 text-right align-top whitespace-nowrap">
<form
id="edit-form-{user.id}"
method="POST"
action="?/createUser"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditUser();
}}
class="flex flex-col items-end gap-2"
{:else}
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
{/if}
</div>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-4">
<a
href="/admin/users/{user.id}"
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
>
<input
type="password"
name="password"
placeholder={m.admin_password_placeholder()}
class="w-32 rounded border border-brand-mint px-2 py-1 text-xs"
/>
{m.btn_edit()}
</a>
<div class="mt-1 flex gap-2">
<button
type="submit"
class="rounded bg-green-600 px-2 py-1 text-xs font-bold text-white uppercase hover:bg-green-700"
>
{m.btn_save()}
</button>
<button
type="button"
onclick={cancelEditUser}
class="rounded bg-gray-200 px-2 py-1 text-xs font-bold text-gray-600 uppercase hover:bg-gray-300"
>
{m.btn_cancel()}
</button>
</div>
</form>
</td>
{:else}
<!-- === VIEW MODE === -->
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{user.username}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#if user.groups && user.groups.length > 0}
{#each user.groups as group (group.id)}
<span
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
>
{group.name}
</span>
{/each}
{:else}
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
{/if}
</div>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-4">
<form
method="POST"
action="?/deleteUser"
use:enhance={({ cancel }) => {
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="flex items-center"
>
<input type="hidden" name="id" value={user.id} />
<button
onclick={() => startEditUser(user.id)}
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
class="p-1 text-gray-300 transition-colors hover:text-red-600"
title={m.admin_btn_delete_user_title()}
>
{m.btn_edit()}
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<form
method="POST"
action="?/deleteUser"
use:enhance={({ cancel }) => {
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="flex items-center"
>
<input type="hidden" name="id" value={user.id} />
<button
class="p-1 text-gray-300 transition-colors hover:text-red-600"
title={m.admin_btn_delete_user_title()}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
{/if}
</form>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Create User Form -->
<div class="border-t border-gray-200 bg-gray-50 p-6">
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
{m.admin_section_new_user()}
</h3>
<form
method="POST"
action="?/createUser"
use:enhance
class="grid grid-cols-1 items-start gap-4 md:grid-cols-6"
>
<input
type="text"
name="username"
placeholder="Login"
required
class="w-full rounded border-gray-300 text-sm"
/>
<input
type="password"
name="password"
placeholder={m.admin_col_password()}
required
class="w-full rounded border-gray-300 text-sm"
/>
<div class="md:col-span-3">
<select
name="groupIds"
multiple
class="h-[42px] w-full rounded border-gray-300 py-1 text-sm"
required
title={m.admin_multiselect_hint_multi()}
>
{#each data.groups as group (group.id)}
<option value={group.id}>{group.name}</option>
{/each}
</select>
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint_full()}</p>
</div>
<button
type="submit"
class="h-[42px] w-full rounded bg-brand-navy text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy"
>{m.btn_create()}</button
>
</form>
</div>
</div>
{:else if activeTab === 'tags'}
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
@@ -601,5 +534,41 @@ function cancelEditGroup() {
</form>
</div>
</div>
{:else if activeTab === 'system'}
<div class="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_heading()}</h2>
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_description()}</p>
<button
onclick={backfillVersions}
disabled={backfillLoading}
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"
>
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
</button>
{#if backfillResult !== null}
<p class="mt-4 text-sm font-medium text-brand-navy">
{m.admin_system_backfill_success({ count: backfillResult })}
</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>

View File

@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
const makeGroup = (overrides = {}) => ({
id: 'g1',
name: 'Editoren',
permissions: ['WRITE_ALL'],
...overrides
});
const makeUser = (overrides = {}) => ({
id: 'u1',
username: 'max',
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@example.com',
birthDate: undefined,
contact: undefined,
enabled: true,
groups: [makeGroup()],
createdAt: '2024-01-01T00:00:00Z',
...overrides
});
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
users: [makeUser()],
groups: [makeGroup()],
tags: []
};
afterEach(cleanup);
// ─── Users tab ────────────────────────────────────────────────────────────────
describe('Admin page users tab', () => {
it('shows the username in the table', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('cell', { name: 'max', exact: true })).toBeInTheDocument();
});
it('shows the full name in the table', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/Max Mustermann/)).toBeInTheDocument();
});
it('shows a dash when user has no name set', async () => {
const data = { ...baseData, users: [makeUser({ firstName: undefined, lastName: undefined })] };
render(Page, { data, form: null });
await expect.element(page.getByText('')).toBeInTheDocument();
});
it('shows group badges for the user', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
});
it('edit link points to /admin/users/[id]', async () => {
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Bearbeiten/i }))
.toHaveAttribute('href', '/admin/users/u1');
});
it('new user button links to /admin/users/new', async () => {
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Neuer Benutzer/i }))
.toHaveAttribute('href', '/admin/users/new');
});
it('shows "no groups" label when user has no groups', async () => {
const data = { ...baseData, users: [makeUser({ groups: [] })] };
render(Page, { data, form: null });
await expect.element(page.getByText(/Keine Gruppen/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,67 @@
import { error, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export const load: PageServerLoad = async ({ params, fetch, locals }) => {
const user = locals.user;
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('ADMIN')
);
if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN'));
const api = createApiClient(fetch);
const [userResult, groupsResult] = await Promise.all([
api.GET('/api/users/{id}', { params: { path: { id: params.id } } }),
api.GET('/api/groups')
]);
if (!userResult.response.ok) throw error(404, getErrorMessage('USER_NOT_FOUND'));
return {
editUser: userResult.data!,
groups: groupsResult.data ?? []
};
};
export const actions: Actions = {
default: async ({ params, request, fetch }) => {
const data = await request.formData();
const newPassword = data.get('newPassword') as string;
const confirmPassword = data.get('confirmPassword') as string;
if (newPassword && newPassword !== confirmPassword) {
return fail(400, { error: getErrorMessage('PASSWORDS_DO_NOT_MATCH') });
}
const birthDateRaw = data.get('birthDate') as string;
const body = {
firstName: (data.get('firstName') as string) || null,
lastName: (data.get('lastName') as string) || null,
birthDate: birthDateRaw || null,
email: (data.get('email') as string) || null,
contact: (data.get('contact') as string) || null,
newPassword: newPassword || null,
groupIds: data.getAll('groupIds') as string[]
};
const res = await fetch(`/api/users/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
let code: string | undefined;
try {
const json = await res.json();
code = json?.code;
} catch {
// ignore
}
return fail(res.status, { error: getErrorMessage(code) });
}
return { success: true };
}
};

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
let { data, form } = $props();
function isoToGerman(iso: string | undefined): string {
if (!iso) return '';
const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return '';
return `${match[3]}.${match[2]}.${match[1]}`;
}
function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
return `${match[3]}-${match[2]}-${match[1]}`;
}
let birthDateDisplay = $state(untrack(() => isoToGerman(data.editUser?.birthDate)));
let birthDateIso = $state(untrack(() => data.editUser?.birthDate ?? ''));
function handleBirthDateInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let formatted: string;
if (digits.length <= 2) {
formatted = digits;
} else if (digits.length <= 4) {
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
input.value = formatted;
birthDateDisplay = formatted;
birthDateIso = germanToIso(formatted);
}
</script>
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<a
href="/admin"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{m.btn_back_to_overview()}
</a>
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">
{m.admin_user_edit_heading({ username: data.editUser.username })}
</h1>
{#if form?.success}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.admin_user_updated()}
</div>
{/if}
{#if form?.error}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<form method="POST" use:enhance class="space-y-6">
<!-- Profile card -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.profile_section_personal()}
</h2>
<div class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_first_name()}
</span>
<input
type="text"
name="firstName"
value={data.editUser.firstName ?? ''}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_last_name()}
</span>
<input
type="text"
name="lastName"
value={data.editUser.lastName ?? ''}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
</div>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_birth_date()}
</span>
<input
type="text"
placeholder="TT.MM.JJJJ"
value={birthDateDisplay}
oninput={handleBirthDateInput}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
<input type="hidden" name="birthDate" value={birthDateIso} />
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_email()}
</span>
<input
type="email"
name="email"
value={data.editUser.email ?? ''}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_contact()}
</span>
<textarea
name="contact"
rows="3"
placeholder={m.profile_contact_placeholder()}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
>{data.editUser.contact ?? ''}</textarea
>
</label>
</div>
</div>
<!-- Groups card -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.admin_col_groups()}
</h2>
<div class="flex flex-wrap gap-3">
{#each data.groups as group (group.id)}
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
name="groupIds"
value={group.id}
checked={data.editUser.groups?.some((g: { id: string }) => g.id === group.id)}
class="rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
/>
{group.name}
</label>
{/each}
</div>
</div>
<!-- Password card -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.admin_label_new_password_optional()}
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_new_password()}
</span>
<input
type="password"
name="newPassword"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_new_password_confirm()}
</span>
<input
type="password"
name="confirmPassword"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
</div>
</div>
<!-- Save bar -->
<div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
>
<a
href="/admin"
class="font-sans text-xs font-bold tracking-widest text-gray-500 uppercase hover:text-brand-navy"
>
{m.btn_cancel()}
</a>
<button
type="submit"
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
{m.btn_save()}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
const groups = [
{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] },
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
];
const makeUser = (overrides = {}) => ({
id: 'u1',
username: 'max',
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@example.com',
birthDate: '1985-03-22',
contact: 'Tel: 0123',
enabled: true,
groups: [{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] }],
createdAt: '2024-01-01T00:00:00Z',
...overrides
});
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
editUser: makeUser(),
groups
};
afterEach(cleanup);
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin edit user page rendering', () => {
it('renders the heading with username', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/Benutzer bearbeiten: max/i)).toBeInTheDocument();
});
it('pre-fills first name from editUser data', async () => {
render(Page, { data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="firstName"]');
expect(input?.value).toBe('Max');
});
it('pre-fills last name from editUser data', async () => {
render(Page, { data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="lastName"]');
expect(input?.value).toBe('Mustermann');
});
it('pre-fills email from editUser data', async () => {
render(Page, { data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input?.value).toBe('max@example.com');
});
it('pre-fills birth date in German format (dd.mm.yyyy)', async () => {
render(Page, { data: baseData, form: null });
const input = document.querySelector<HTMLInputElement>('input[placeholder="TT.MM.JJJJ"]');
expect(input?.value).toBe('22.03.1985');
});
it('pre-fills contact field', async () => {
render(Page, { data: baseData, form: null });
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[name="contact"]');
expect(textarea?.value).toBe('Tel: 0123');
});
it('renders group checkboxes', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
await expect.element(page.getByText('Admins')).toBeInTheDocument();
});
it('pre-selects the groups the user already belongs to', async () => {
render(Page, { data: baseData, form: null });
const checkbox = document.querySelector<HTMLInputElement>(
'input[type="checkbox"][name="groupIds"][value="g1"]'
);
expect(checkbox?.checked).toBe(true);
});
it('does not pre-select groups the user does not belong to', async () => {
render(Page, { data: baseData, form: null });
const checkbox = document.querySelector<HTMLInputElement>(
'input[type="checkbox"][name="groupIds"][value="g2"]'
);
expect(checkbox?.checked).toBe(false);
});
it('password fields are empty by default', async () => {
render(Page, { data: baseData, form: null });
const passwordInputs = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
passwordInputs.forEach((input) => {
expect(input.value).toBe('');
});
});
it('cancel link points to /admin', async () => {
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin');
});
it('renders the save button', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('button', { name: /Speichern/i })).toBeInTheDocument();
});
});
// ─── Feedback messages ────────────────────────────────────────────────────────
describe('Admin edit user page feedback', () => {
it('shows success message when form.success is true', async () => {
render(Page, { data: baseData, form: { success: true } });
await expect.element(page.getByText(/Änderungen gespeichert/i)).toBeInTheDocument();
});
it('shows error message when form.error is set', async () => {
render(Page, { data: baseData, form: { error: 'Ungültige Eingabe.' } });
await expect.element(page.getByText('Ungültige Eingabe.')).toBeInTheDocument();
});
it('does not show success message when form is null', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/Änderungen gespeichert/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,45 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const user = locals.user;
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('ADMIN')
);
if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN'));
const api = createApiClient(fetch);
const groupsResult = await api.GET('/api/groups');
return { groups: groupsResult.data ?? [] };
};
export const actions: Actions = {
default: async ({ request, fetch }) => {
const data = await request.formData();
const api = createApiClient(fetch);
const birthDateRaw = data.get('birthDate') as string;
const result = await api.POST('/api/users', {
body: {
username: data.get('username') as string,
initialPassword: data.get('password') as string,
email: (data.get('email') as string) || undefined,
groupIds: data.getAll('groupIds') as string[],
firstName: (data.get('firstName') as string) || null,
lastName: (data.get('lastName') as string) || null,
birthDate: birthDateRaw || null,
contact: (data.get('contact') as string) || null
}
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, '/admin');
}
};

View File

@@ -0,0 +1,204 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
let { data, form } = $props();
function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
return `${match[3]}-${match[2]}-${match[1]}`;
}
let birthDateIso = $state('');
function handleBirthDateInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let formatted: string;
if (digits.length <= 2) {
formatted = digits;
} else if (digits.length <= 4) {
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
input.value = formatted;
birthDateIso = germanToIso(formatted);
}
</script>
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<a
href="/admin"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{m.btn_back_to_overview()}
</a>
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.admin_user_new_heading()}</h1>
{#if form?.error}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<form method="POST" use:enhance class="space-y-5">
<!-- Account -->
<h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.admin_section_users()}
</h2>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.admin_col_login()}
</span>
<input
type="text"
name="username"
required
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.admin_label_initial_password()}
</span>
<input
type="password"
name="password"
required
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<!-- Profile -->
<h2 class="pt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.profile_section_personal()}
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_first_name()}
</span>
<input
type="text"
name="firstName"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_last_name()}
</span>
<input
type="text"
name="lastName"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
</div>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_birth_date()}
</span>
<input
type="text"
placeholder="TT.MM.JJJJ"
oninput={handleBirthDateInput}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
<input type="hidden" name="birthDate" value={birthDateIso} />
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_email()}
</span>
<input
type="email"
name="email"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_contact()}
</span>
<textarea
name="contact"
rows="3"
placeholder={m.profile_contact_placeholder()}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
></textarea>
</label>
<!-- Groups -->
<h2 class="pt-2 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.admin_col_groups()}
</h2>
<div class="flex flex-wrap gap-3">
{#each data.groups as group (group.id)}
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
name="groupIds"
value={group.id}
class="rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
/>
{group.name}
</label>
{/each}
</div>
<!-- Save bar -->
<div
class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm"
>
<a
href="/admin"
class="font-sans text-xs font-bold tracking-widest text-gray-500 uppercase hover:text-brand-navy"
>
{m.btn_cancel()}
</a>
<button
type="submit"
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
{m.btn_create()}
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
const groups = [
{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] },
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
];
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
afterEach(cleanup);
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin new user page rendering', () => {
it('renders the page heading', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument();
});
it('renders the login input', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('textbox', { name: /Login/i })).toBeInTheDocument();
});
it('renders group checkboxes for each available group', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText('Editoren')).toBeInTheDocument();
await expect.element(page.getByText('Admins')).toBeInTheDocument();
});
it('cancel link points to /admin', async () => {
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin');
});
it('back link points to /admin', async () => {
render(Page, { data: baseData, form: null });
await expect
.element(page.getByRole('link', { name: /Zurück/i }))
.toHaveAttribute('href', '/admin');
});
it('renders the create button', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('button', { name: /Erstellen/i })).toBeInTheDocument();
});
});
// ─── Error display ────────────────────────────────────────────────────────────
describe('Admin new user page error display', () => {
it('shows the error message when form has an error', async () => {
render(Page, { data: baseData, form: { error: 'Ein Fehler ist aufgetreten.' } });
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).toBeInTheDocument();
});
it('does not show error section when form is null', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument();
});
});

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

@@ -1,19 +1,33 @@
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ params, fetch }) {
const { id } = params;
const api = createApiClient(fetch);
const base = env.API_INTERNAL_URL || 'http://localhost:8080';
const result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
const [docResult, commentsRes] = await Promise.all([
api.GET('/api/documents/{id}', { params: { path: { id } } }),
fetch(`${base}/api/documents/${id}/comments`).catch(() => null)
]);
if (result.response.status === 401) throw redirect(302, '/login');
if (docResult.response.status === 401) throw redirect(302, '/login');
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
if (!docResult.response.ok) {
const code = (docResult.error as unknown as { code?: string })?.code;
throw error(docResult.response.status, getErrorMessage(code));
}
return { document: result.data! };
let comments: unknown[] = [];
if (commentsRes?.ok) {
try {
comments = await commentsRes.json();
} catch {
// ignore invalid response
}
}
return { document: docResult.data!, comments };
}

Some files were not shown because too many files have changed in this diff Show More