Compare commits

...

95 Commits

Author SHA1 Message Date
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
Marcel
9731afb776 fix(auth): pass through explicit Authorization header in handleFetch
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m9s
CI / Backend Unit Tests (pull_request) Successful in 2m2s
CI / E2E Tests (pull_request) Failing after 17m15s
CI / Unit & Component Tests (push) Successful in 2m4s
CI / Backend Unit Tests (push) Successful in 2m0s
CI / E2E Tests (push) Failing after 16m27s
The login action sends Basic auth via an explicit Authorization header.
handleFetch was intercepting this request and returning 401 because no
auth_token cookie exists yet (the user isn't logged in), never forwarding
the credentials to the backend.

Fix: if the outgoing request already has an Authorization header, pass it
through unchanged. Only inject the cookie-based token for requests that
don't provide their own auth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:38:01 +01:00
Marcel
f6634f1d00 fix(tests): fix Svelte 5 event delegation not firing via Playwright locator clicks
Replace Playwright locator .click() calls with native DOM element.click()
for all tests that trigger Svelte 5 delegated onclick handlers ($.delegated).
Playwright's CDP-based synthetic events don't propagate through Svelte 5's
document-level handle_event_propagation delegation mechanism, while native
DOM .click() does.

Also replace locator.click() with element.focus() for onfocus handler tests,
and add cleanup() to afterEach in all spec files missing it to prevent test
pollution between runs. Fix TagInput.svelte to use untrack() when reading
bindable state after an await to avoid track_reactivity_loss errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:34:56 +01:00
Marcel
18601db4f8 fix(profile): use dd.mm.yyyy date input for birth date field
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m4s
CI / Backend Unit Tests (pull_request) Successful in 1m57s
CI / E2E Tests (pull_request) Failing after 13m53s
CI / Unit & Component Tests (push) Successful in 2m35s
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Replace the browser-native type="date" picker with a text input using
the same german format (dd.mm.yyyy with auto-dot insertion) as the
document date fields. A hidden input sends the ISO value to the server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:18:40 +01:00
Marcel
a65c69b0ce fix(tests): fix type errors in spec files after adding user to App.PageData
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 1m57s
CI / Backend Unit Tests (pull_request) Successful in 1m55s
CI / E2E Tests (pull_request) Failing after 14m6s
Add user: undefined to baseData in conversations and documents/new specs.
Change null to undefined for filePath/transcription in makeDoc fixture.
Add form: null to render calls missing it.
Fix birthYear conversion from string to number in persons/[id] server action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:05:08 +01:00
Marcel
8f5c13f162 fix(frontend): fix handleFetch skipping auth for /api/users/me endpoints and regenerate API types
The handleFetch hook previously skipped auth headers for all URLs
containing /api/users/me. Since the hook's own user-load call uses
globalThis.fetch (bypassing handleFetch), it is safe to remove this
exception — enabling profile update and password change actions to
authenticate properly.

Also regenerates API types with new profile endpoints and AppUser fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:04:37 +01:00
Marcel
168225d67c feat(frontend): add profile page and public user profile page
/profile: two-card layout with personal info form (name, birth date,
email, contact) and password change form, each with independent actions.
/users/[id]: read-only public view showing name, username, email, contact
with avatar circle initials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:04:10 +01:00
Marcel
401a1f359f feat(frontend): replace logout button with user avatar dropdown in nav
Show user initials (e.g. MM) in a circular button when name is set,
or a fallback person icon. Clicking opens a dropdown with links to
/profile and a logout form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:03:42 +01:00
Marcel
82c8401167 feat(frontend): add i18n messages and error codes for profile feature
Add profile_* message keys for the profile page forms in de/en/es.
Add EMAIL_ALREADY_IN_USE and WRONG_CURRENT_PASSWORD to ErrorCode type and
getErrorMessage switch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:03:19 +01:00
Marcel
2f803b2740 feat(backend): add user profile fields and profile/password endpoints
Add firstName, lastName, birthDate, contact to AppUser via V7 migration.
Add PUT /api/users/me and POST /api/users/me/password endpoints.
Add GET /api/users/{id} for public profile lookup.
Add EMAIL_ALREADY_IN_USE and WRONG_CURRENT_PASSWORD error codes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:02:55 +01:00
Marcel
da0d5495d0 fix(persons): prevent stale navigation from clobbering focused search input
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 2m12s
CI / Backend Unit Tests (pull_request) Successful in 1m58s
CI / E2E Tests (pull_request) Successful in 17m40s
CI / Unit & Component Tests (push) Successful in 1m58s
CI / Backend Unit Tests (push) Successful in 1m59s
CI / E2E Tests (push) Successful in 14m56s
The persons list search input used value={data.q || ''} bound directly to
server data, so every navigation completion would reset it to the URL value
mid-typing, dropping keystrokes just like issue #34 on the home page.

Apply the same focus-guard fix: introduce local `q` state, a `qFocused`
flag, and a guarded $effect that only syncs URL → state when the input is
not focused. Adds a regression test matching the home-page pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:54:56 +01:00
Marcel
513a7290b0 fix(conversations): keep swap button in DOM to prevent grid column width shift
All checks were successful
CI / Unit & Component Tests (push) Successful in 1m56s
CI / Backend Unit Tests (push) Successful in 1m53s
CI / E2E Tests (push) Successful in 16m37s
CI / Unit & Component Tests (pull_request) Successful in 2m2s
CI / Backend Unit Tests (pull_request) Successful in 2m2s
CI / E2E Tests (pull_request) Successful in 16m55s
The swap button was conditionally removed from the DOM with {#if}, which
caused the receiver input to collapse into the narrow auto column of the
grid-cols-[1fr_auto_1fr] layout on desktop when no persons were selected.

The button is now always rendered. On desktop it becomes invisible
(visibility:hidden) when no persons are selected, preserving the middle
column width so both 1fr columns stay equal. On mobile it remains hidden
(display:none) via the hidden class so no empty gap appears between the
stacked inputs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
0f8b582813 test(documents): add component tests for sender/receiver URL prefill on new document page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
4026bb9003 feat(documents): prefill sender and receiver from URL params on new document page
When navigating from the conversations page via the 'New document in this
correspondence' link, the senderId and receiverId query params are now read
in the server load, resolved to person names, and used to pre-populate the
sender typeahead and receiver multi-select on the form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
f2f9a1bf03 test(conversations): add component tests for new features
Covers: empty state, swap button (visible/hidden, goto called with
swapped params), summary content, year dividers, and new document link
visibility gated by canWrite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
76031de8eb fix(conversations): restore {#if} guard on swap button
The guard was lost when the button was moved into the grid between the
two person inputs. Without it the button rendered even when no persons
were selected, breaking the UX and the E2E assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
e2874528cd fix(conversations): hide new document link for read-only users
The link navigates to a page that requires WRITE_ALL. Guard it with
data.canWrite (supplied by the layout) so read-only users never see a
link that leads to a 403.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
aa127de9bd refactor(conversations): move swap button between person input fields
On desktop the button sits between the two typeaheads as an icon-only
button (icon rotated 90° to point left/right) aligned to the input
baseline. On mobile it renders full-width with the label text between
the stacked fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
65a8048e25 feat(conversations): add new document link pre-filled with both persons (#33)
Adds a link next to the summary that navigates to the new-document form
with senderId and receiverId pre-filled from the current conversation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
1ab063486c feat(conversations): add year dividers between documents (#30)
Renders a horizontal rule with the year label between consecutive
documents that belong to different years.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
0a1075e03f feat(conversations): add summary with document count and year range (#31)
Shows a summary line above the conversation listing with total document
count and the year span, e.g. "4 Dokumente · 1923–1965".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
ca212e871f feat(conversations): add swap button (#32)
Adds a button between the two person typeaheads that swaps sender and
receiver, then reloads the conversation view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:32:23 +01:00
Marcel
0525e66d55 feat(conversations): filter person typeahead to correspondents of selected person
All checks were successful
CI / E2E Tests (push) Successful in 18m17s
CI / Unit & Component Tests (push) Successful in 3m37s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / Unit & Component Tests (pull_request) Successful in 2m12s
CI / Backend Unit Tests (pull_request) Successful in 2m1s
CI / E2E Tests (pull_request) Successful in 15m17s
Closes #29

Backend:
- Add PersonRepository.findCorrespondents / findCorrespondentsWithFilter
  (native SQL, orders by shared document count DESC, limit 10)
- Add PersonService.findCorrespondents(personId, q) delegating to the
  correct repository method based on whether a query string is present
- Expose GET /api/persons/{id}/correspondents?q= in PersonController

Frontend:
- Add optional restrictToCorrespondentsOf prop to PersonTypeahead
- On focus with the prop set, fetch correspondents immediately (no typing
  required) — opens the dropdown showing top correspondents
- On input with the prop set, hit the correspondents endpoint with q= param
- Without the prop, keep existing /api/persons?q= behaviour unchanged
- Wire the prop bidirectionally in /conversations: sender restricts receiver
  and vice versa

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:23:11 +01:00
Marcel
acf6fc05ad fix(search): prevent stale navigation from clobbering focused search input
Some checks failed
CI / E2E Tests (push) Waiting to run
CI / Unit & Component Tests (push) Successful in 10m14s
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (pull_request) Failing after 13m49s
CI / Backend Unit Tests (pull_request) Failing after 13m59s
CI / Unit & Component Tests (pull_request) Failing after 14m9s
The sync $effect on the home page unconditionally overwrote the local `q`
state with the URL value after every navigation. When users typed faster
than a navigation round-trip (debounce fires → goto() → data reloads),
the completed navigation wrote the stale URL value back into the input,
dropping the characters typed in the interim.

Guard the `q` assignment in the effect with a `qFocused` flag (set via
onfocus/onblur on the text input). Covers issue #34.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:57:23 +01:00
Marcel
f950e4e826 docs(codestyle): add Svelte 5 specific rules with examples
All checks were successful
CI / Unit & Component Tests (push) Successful in 1m52s
CI / Backend Unit Tests (push) Successful in 1m59s
CI / E2E Tests (push) Successful in 16m21s
Document the three key rules enforced by eslint-plugin-svelte:
- svelte/require-each-key: why position-based tracking silently corrupts state
- svelte/prefer-writable-derived: why $state+$effect is wrong for computed values
- svelte/prefer-svelte-reactivity: why SvelteMap/SvelteURLSearchParams are needed

Each rule includes bad/good code examples and a technical reason.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 15:58:40 +01:00
Marcel
db2fc33e99 fix(frontend): enforce lint locally and in CI, fix all pre-existing violations
Some checks failed
CI / Unit & Component Tests (push) Successful in 1m59s
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
## Pre-commit hook
- Add .husky/pre-commit at repo root: runs `cd frontend && npm run lint`
- Update prepare script in package.json to auto-configure git hooks path
  on npm install (git -C .. config core.hooksPath .husky)
- Add lint step to CI unit-tests job so it catches issues before tests run
- Add generated dirs to .prettierignore (paraglide_bak*, test-results, .auth)
- Add src/lib/paraglide_bak* to .gitignore so ESLint can ignore them

## ESLint fixes (all pre-existing)
- Disable svelte/no-navigation-without-resolve: false positive in SvelteKit
  (rule targets Svelte 5 standalone routing, not SvelteKit <a href>)
- Fix svelte/require-each-key: add (item.id)/(item) keys to all {#each} blocks
  across 10 files — improves Svelte reconciliation performance
- Fix svelte/prefer-writable-derived in PersonTypeahead: $state+$effect → $derived
- Fix svelte/prefer-svelte-reactivity: URLSearchParams → SvelteURLSearchParams,
  Map → SvelteMap (enables Svelte reactive tracking)
- Fix @typescript-eslint/no-unused-vars: remove dead imports/variables

## Prettier
- Run npm run format to bring all source files in line with .prettierrc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 15:55:42 +01:00
Marcel
28dea45cc3 fix(i18n): remove trailing comma from all three message files
All checks were successful
CI / Unit & Component Tests (push) Successful in 1m39s
CI / Backend Unit Tests (push) Successful in 1m51s
CI / E2E Tests (push) Successful in 16m21s
Standard JSON does not allow trailing commas. The comma after the last
key in de/en/es.json caused paraglide to fail compilation, which meant
messages.js was never generated and all component tests crashed on import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 12:33:55 +01:00
Marcel
11f6f9e2a2 refactor(frontend): apply formatDate utility and fix derived/error handling
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m37s
CI / Backend Unit Tests (push) Successful in 2m2s
CI / E2E Tests (push) Has started running
- Replace 5 inline Intl.DateTimeFormat blocks with formatDate() across
  home, conversations, persons detail, and document detail pages
- Fix coCorrespondents: $derived(() => ...) → $derived.by(...) —
  the old form typed the value as a function, breaking template call sites
- Persons list: throw error on API failure instead of silently returning []

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 12:11:42 +01:00
Marcel
4771832492 refactor(frontend): extract toActionResult helper and formatDate utility
- Admin page: replace 7 identical error-handling blocks with a single
  toActionResult() helper — DRY without over-abstraction
- New date.ts util: formatDate(isoDate) centralises the T12:00:00
  timezone guard and Intl.DateTimeFormat locale config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 12:10:04 +01:00
Marcel
c006113db9 refactor(backend): replace 7-parameter updatePerson with PersonUpdateDTO
Reduces parameter count from 7 to 2 (id + dto), keeping all validation
and trimming logic in the service. Controller now binds request JSON
directly to the DTO via @RequestBody.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:52:16 +01:00
Marcel
5160009175 refactor(backend): fix typos, dead debug logs, and bare orElseThrow calls
- UserService: remove debug log dumping all DB groups ("Groupds in DB"),
  fix indentation of createUserOrUpdate, clean up log messages
- DocumentService: fix typo reciever → receiver in searchDocuments parameter,
  remove broken log.info("Tags", tags) with missing format specifier,
  replace bare orElseThrow() with DomainException in updateDocumentTags
  and createDocument, remove what-comments on Lombok annotations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:50:16 +01:00
Marcel
4009781064 docs: add CODESTYLE.md with Clean Code, DRY/KISS, and SOLID principles
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m26s
CI / Backend Unit Tests (push) Successful in 1m59s
CI / E2E Tests (push) Failing after 15m42s
Covers naming, function design, guard clauses, comment policy, command-query
separation, DRY vs KISS trade-offs (KISS wins), and SOLID applied to the
Java backend and TypeScript/Svelte frontend. Linked from CLAUDE.md and
COLLABORATING.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:36:34 +01:00
Marcel
761c903111 refactor(person): remove redundant conversations link from header
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m17s
CI / Backend Unit Tests (push) Successful in 1m52s
CI / E2E Tests (push) Has started running
The co-correspondent chips already link directly to the conversation view
pre-filled with both persons, making the generic "Konversationen anzeigen"
header link redundant. Removed the link and the person_btn_conversations
i18n key from all three locales.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:27:25 +01:00
Marcel
931a8dac95 refactor(person): move sort button into each section heading, sort independently
Some checks are pending
CI / Unit & Component Tests (push) Successful in 1m43s
CI / Backend Unit Tests (push) Successful in 1m57s
CI / E2E Tests (push) Has started running
Replaced the single shared sort control with per-section sort buttons placed
inline in each heading row (right-aligned via ml-auto). Each section now sorts
independently, which matches user expectation and keeps the control visually
anchored to the list it affects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:22:56 +01:00
Marcel
3f717e3266 refactor(person): fold year range into section headings, remove standalone stats bar
Some checks are pending
CI / Unit & Component Tests (push) Successful in 1m39s
CI / Backend Unit Tests (push) Successful in 2m8s
CI / E2E Tests (push) Has started running
The floating stats bar was visually disconnected and showed a combined document
count already visible from the per-section badges. Replaced it with a year range
shown inline next to each section heading (e.g. "Gesendete Dokumente · 12 · 1921–1945"),
making the range contextually relevant per direction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:16:22 +01:00
Marcel
203b7d2b08 fix(auth): proxy document file requests server-side to prevent Basic Auth popup
Some checks are pending
CI / Unit & Component Tests (push) Successful in 1m55s
CI / Backend Unit Tests (push) Successful in 2m8s
CI / E2E Tests (push) Has started running
Client-side fetch('/api/documents/{id}/file') bypassed the handleFetch hook
that injects the Authorization header, causing the browser to receive a 401
with WWW-Authenticate: Basic and show a native auth dialog.

Added a SvelteKit server route at /api/documents/[id]/file that proxies the
request through the server, where handleFetch injects the auth cookie correctly.

Also fixed E2E default password (admin → admin123) to match application.yaml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:01:31 +01:00
Marcel
e9b03ee6a9 fix(person): add i18n for show-more button and limit doc lists to 5
All checks were successful
CI / Unit & Component Tests (push) Successful in 1m59s
CI / Backend Unit Tests (push) Successful in 2m9s
CI / E2E Tests (push) Successful in 16m33s
- Add person_show_more key (DE/EN/ES)
- Limit sent/received document lists to 5 with a translated "show more" button
- Co-correspondent chips now link to /conversations?senderId=...&receiverId=...

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:13:38 +01:00
Marcel
ba04e62f87 fix(person): remove redundant role badges from document lists
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
The Gesendet/Empfangen badge is redundant since documents already appear
in separate Gesendete/Empfangene sections.

Refs #21
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:05:19 +01:00
Marcel
fa4bfb8e5c feat(routes): add server-side WRITE_ALL guard on write-only routes
Block direct URL navigation to /persons/new, /documents/new,
/documents/:id/edit for users without WRITE_ALL permission.
E2E tests verify admin user retains access to all write routes.

Closes #17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:47:52 +01:00
Marcel
fde75f3fcf feat(ui): hide write UI from users without WRITE_ALL permission
Wrap write-only elements with {#if data.canWrite} in:
- Home page: Neues Dokument link
- Persons list: Neue Person link
- Document detail: Bearbeiten button
- Person detail: edit button, edit form, merge section

Refs #17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:47:45 +01:00
Marcel
03a1a86cdb feat(layout): expose canWrite flag from layout server load
Derives canWrite from WRITE_ALL permission in user groups, available
as page.data.canWrite on every page without per-page boilerplate.

Refs #17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:47:37 +01:00
Marcel
55ffaa1c5c feat(person): show received docs, role badges, stats bar, co-correspondents
- Split document list into Gesendete / Empfangene Dokumente sections
- Add role badges (Gesendet / Empfangen) on each document card
- Add statistics strip showing total count and year range
- Add co-correspondents section with frequency-sorted chips
- Single sort toggle applies to both sections

Closes #1 Closes #19 Closes #21 Closes #22
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:39:50 +01:00
Marcel
1fdde95b09 feat(frontend): load sent and received documents for person detail
Split single documents load into sentDocuments and receivedDocuments,
fetched in parallel via Promise.all.

Refs #1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:39:37 +01:00
Marcel
c056d804e6 feat(i18n): add translations for received docs, role badges, and co-correspondents
Add keys: person_received_docs_heading, person_no_received_docs,
person_role_sender, person_role_receiver, person_co_correspondents_heading
in DE, EN, ES.

Refs #1 Refs #21 Refs #22
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:39:30 +01:00
Marcel
490382b5de feat(api): regenerate TypeScript types with received-documents endpoint
Refs #1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:39:24 +01:00
Marcel
557b62ac5c feat(backend): add received-documents endpoint for persons
Add findByReceiversId to DocumentRepository, getDocumentsByReceiver
to DocumentService, and GET /api/persons/{id}/received-documents
to PersonController. Tests added for both service and controller layers.

Closes #1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:39:18 +01:00
Marcel
4ccc8d69d0 docs(collab): add atomic commits rule to commit message guidelines
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:47:52 +01:00
Marcel
c3f487f16c fix(e2e+i18n): add missing DE translation, fix E2E test selectors
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
- Add missing person_btn_conversations translation to de.json
- Fix birth/death year test: exclude /persons/new link + wait for hydration
- Fix lang test switching back to DE: wait for hydration + clear locale cookie
  (headless Chromium doesn't reliably delete cookies via document.cookie)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:45:56 +01:00
Marcel
6e6663376d fix(migrations): make V5/V6 idempotent with IF NOT EXISTS
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
Avoids Flyway errors when columns already exist in the DB due to
migration history mismatches from parallel feature branches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:14:44 +01:00
Marcel
041bbdc2e6 merge(feat/person-birth-death-years): resolve conflicts with main, bump migration to V6
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 1m45s
CI / Backend Unit Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Failing after 18m40s
CI / Unit & Component Tests (push) Successful in 1m57s
CI / Backend Unit Tests (push) Successful in 2m13s
CI / E2E Tests (push) Failing after 18m39s
Resolves merge conflicts with main (feat/person-notes merged first).
Combines both features: birth/death years and notes field on person detail.
Renames migration V5__add_birth_death_years to V6 to avoid Flyway conflict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:04:44 +01:00
Marcel
08f7ae9a5c feat(persons): add notes field to person profile (issue #23)
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
V5 Flyway migration adds TEXT notes column; Person entity, service, and
controller updated to persist notes. Frontend edit form adds textarea and
view mode renders the notes section. Backed by 2 new service unit tests
(persist + blank clears).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:57:05 +01:00
Marcel
c01a07bd82 fix(e2e): fix conversations link test — exclude /persons/new and use specific link text
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 9h3m35s
CI / Backend Unit Tests (pull_request) Successful in 3m51s
CI / E2E Tests (pull_request) Failing after 17m8s
CI / Unit & Component Tests (push) Successful in 1m52s
CI / Backend Unit Tests (push) Successful in 2m3s
CI / E2E Tests (push) Failing after 16m23s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:48:58 +01:00
Marcel
cccc12429c chore: resolve merge conflict with main (keep both sort and conversations tests)
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 1m48s
CI / Backend Unit Tests (pull_request) Successful in 2m8s
CI / E2E Tests (pull_request) Failing after 3m40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:46:47 +01:00
Marcel
b07391541b feat(persons): add birth/death year fields (issue #18)
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 1m48s
CI / Backend Unit Tests (pull_request) Successful in 2m3s
CI / E2E Tests (pull_request) Failing after 17m10s
V5 Flyway migration adds birth_year and death_year INTEGER columns.
Service validates birthYear <= deathYear (400 otherwise). Frontend edit
form adds year number inputs; view mode renders * year / † year. Backed
by 3 backend service tests and 1 E2E test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:45:02 +01:00
Marcel
371d92f52a feat(person): add conversations quick-link (#20)
Some checks failed
CI / E2E Tests (push) Failing after 17m44s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 1m51s
CI / Backend Unit Tests (pull_request) Successful in 2m3s
CI / E2E Tests (pull_request) Failing after 17m7s
Add a "Konversationen anzeigen" link to the person detail page header
that navigates to /conversations?senderId={id}, pre-filling the person
as Person A. Includes i18n in de/en/es and an E2E test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:19:36 +01:00
150 changed files with 10502 additions and 2273 deletions

View File

@@ -28,6 +28,10 @@ jobs:
run: npm ci
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Run unit and component tests
run: npm test
working-directory: frontend
@@ -186,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()

1
.husky/pre-commit Executable file
View File

@@ -0,0 +1 @@
cd frontend && npm run lint

View File

@@ -8,7 +8,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Collaboration
See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, the Research → Plan → Implement → Validate cycle, and code style expectations.
See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, and the Research → Plan → Implement → Validate cycle.
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
---

329
CODESTYLE.md Normal file
View File

@@ -0,0 +1,329 @@
# Code Style Guide
This document defines the coding standards for the Familienarchiv project. It applies to both the Java backend and the TypeScript/Svelte frontend. When in doubt, prefer code that a competent developer can read and understand without explanation.
---
## Clean Code (Uncle Bob)
These are principles, not laws. Apply judgment.
### Names reveal intent
A name should tell you *why* something exists, what it does, and how it is used — without needing a comment to explain it.
```java
// Bad
int d; // elapsed time in days
List<Document> list2;
// Good
int elapsedDays;
List<Document> receivedDocuments;
```
- No abbreviations unless universally understood (`id`, `url`, `dto`).
- Boolean variables and methods should read as yes/no questions: `isEnabled`, `hasFile`, `canWrite`.
- Avoid redundant context: inside class `Document`, write `getTitle()` not `getDocumentTitle()`.
### Functions do one thing
A function that does one thing can rarely be meaningfully subdivided. If you can extract a chunk with a name that isn't just a restatement of what it does, it should probably be its own function.
```java
// Bad — validates, transforms, and persists
public Document saveDocument(DocumentUpdateDTO dto) {
if (dto.getTitle() == null) throw new DomainException(...);
String cleaned = dto.getTitle().strip();
Document doc = documentRepository.findById(...).orElseThrow(...);
doc.setTitle(cleaned);
return documentRepository.save(doc);
}
// Good — one responsibility per method; caller orchestrates
public Document updateDocument(UUID id, DocumentUpdateDTO dto) {
validateTitle(dto.getTitle());
Document doc = getById(id);
applyUpdate(doc, dto);
return documentRepository.save(doc);
}
```
### Small functions, minimal parameters
- Functions longer than ~20 lines are a signal to look for a natural split.
- Aim for ≤ 3 parameters. More than 3 is a sign the function is doing too much, or parameters should be grouped into an object.
- Never use boolean flag arguments — they announce the function does two things:
```java
// Bad
renderDocument(doc, true); // what does true mean?
// Good
renderDocumentWithPreview(doc);
renderDocumentWithoutPreview(doc);
```
### Guard clauses over deep nesting
Return or throw early for preconditions. Keep the happy path at the lowest nesting level.
```java
// Bad
public Document getDocument(UUID id, AppUser user) {
if (id != null) {
Document doc = repository.findById(id).orElse(null);
if (doc != null) {
if (user.canRead(doc)) {
return doc;
}
}
}
return null;
}
// Good
public Document getDocument(UUID id, AppUser user) {
if (id == null) throw DomainException.notFound(...);
Document doc = repository.findById(id)
.orElseThrow(() -> DomainException.notFound(...));
if (!user.canRead(doc)) throw DomainException.forbidden(...);
return doc;
}
```
### Comments: only for *why*, never for *what*
Code explains what it does. Comments explain why a non-obvious decision was made.
```typescript
// Bad — restates the code
// set auth cookie
cookies.set('auth_token', authHeader, { path: '/' });
// Good — explains a non-obvious constraint
// secure: false until the deployment is served over HTTPS
cookies.set('auth_token', authHeader, { path: '/', secure: false });
```
If you feel compelled to write a comment that explains *what* the code does, rewrite the code until it doesn't need one.
### No dead code
Remove commented-out code, unused variables, unused imports, and unreachable branches. Version control is the history — dead code in the file is noise.
### Command-query separation
A function either *does something* (command) or *answers something* (query) — not both.
```typescript
// Bad — modifies state and returns a value
function addTagAndReturnCount(tag: string): number { ... }
// Good — separate concerns
function addTag(tag: string): void { ... }
function getTagCount(): number { ... }
```
---
## DRY vs KISS — KISS wins
**DRY** (Don't Repeat Yourself): every piece of knowledge has a single, authoritative representation.
**KISS** (Keep It Simple, Stupid): prefer the simplest solution that works.
**When they conflict, KISS wins.** Do not create an abstraction to eliminate duplication unless the abstraction has a clear, stable name and genuinely reduces cognitive load.
### Practical rules
**Extract when:**
- The same logic appears in 3+ places *and* it has a meaningful name that isn't just a description of the lines it replaces.
- The extracted unit is independently testable.
- The abstraction makes the call site *more* readable, not less.
**Don't extract when:**
- Two things look similar but might diverge independently — coupling them through an abstraction would make future changes harder.
- The extracted function would be used exactly once.
- Naming the abstraction requires a long or awkward name.
```typescript
// Three similar lines — do NOT abstract prematurely
const sentYearRange = yearRange(sentDocuments);
const receivedYearRange = yearRange(receivedDocuments);
// yearRange() is worth extracting because it has a clear name,
// is used in multiple places, and is independently testable.
// But if it were only used once, keep it inline.
```
---
## SOLID Principles
Applied to this stack.
### S — Single Responsibility
Each class, service, or component has one reason to change. In practice:
- **Backend:** Controllers receive HTTP, delegate everything to services. Services contain business logic, never touch another domain's repository directly.
- **Frontend:** Components render UI. Server files (`+page.server.ts`) load and validate data. Don't put business logic in Svelte components.
- **Wrong:** A `DocumentService` that also manages user sessions.
- **Right:** `DocumentService` owns documents; `UserService` owns users; each is ignorant of the other's internal details.
### O — Open/Closed
Code should be open for extension and closed for modification. Prefer adding new code over editing existing code to support new behavior.
```java
// Bad — adding a new export format requires editing this method
public byte[] export(String format) {
if (format.equals("csv")) { ... }
else if (format.equals("pdf")) { ... } // added later, modifies existing method
}
// Good — each format is a separate implementation
public interface DocumentExporter {
byte[] export(List<Document> documents);
}
public class CsvExporter implements DocumentExporter { ... }
public class PdfExporter implements DocumentExporter { ... }
```
In practice: when adding a variant of existing behavior, reach for a new class/function before editing an existing one.
### L — Liskov Substitution
Subtypes must be usable wherever the parent type is expected, without breaking behavior. Concretely:
- If you extend a service or implement an interface, the subtype must honor the contracts (error cases, return semantics) of the parent.
- Don't override a method to make it a no-op or throw unconditionally — that breaks callers who rely on the contract.
### I — Interface Segregation
Don't force callers to depend on methods they don't use. Keep interfaces and services focused.
```java
// Bad — DocumentService exposed to ImportService even though import only needs findOrCreate
public class MassImportService {
private final DocumentService documentService; // 40+ methods, only 2 needed
}
// Good — expose only what's needed via a targeted service method or a narrow interface
public class MassImportService {
private final PersonService personService; // only needs findOrCreateByName
private final TagService tagService; // only needs findOrCreate
}
```
### D — Dependency Inversion
High-level modules should not depend on low-level modules. Both should depend on abstractions.
- **Backend:** Spring's `@Autowired` / constructor injection handles this. Always inject interfaces or Spring beans, never instantiate services with `new` inside a controller or service.
- **Frontend:** Pass data into components via props rather than fetching it inside the component. Components should receive data; server files should supply it.
```typescript
// Bad — component fetches its own data (depends on network/fetch implementation)
onMount(async () => {
persons = await fetch('/api/persons').then(r => r.json());
});
// Good — data flows in via props from the server load function
let { data } = $props(); // data.persons supplied by +page.server.ts
```
---
## Formatting and Style Specifics
These complement the principles above with project-specific conventions.
### Both Java and TypeScript
- One concept per line — don't chain side-effects.
- No magic numbers — extract named constants.
- Fail fast: validate inputs at the boundary (controller / server load), trust internal code.
### Java (backend)
- Use `DomainException` static factories for all domain errors — never throw raw `RuntimeException`.
- `@Transactional` only on write methods, not reads.
- Entities use `@Builder` — construct with builder pattern, not setters, in tests.
- Avoid `Optional.get()` without `orElseThrow` — always provide a meaningful exception.
### TypeScript / Svelte (frontend)
- `$derived` over `$effect` for computed values — effects are for side-effects only.
- Check `!result.response.ok` for API errors, not `result.error` (see CLAUDE.md).
- Prefer typed API client calls over raw `fetch` — use raw `fetch` only for multipart uploads.
- Svelte component logic in `<script>`, layout/styles in template — no business logic in markup.
---
## Svelte 5 — Specific Rules
These rules are enforced by ESLint (`eslint-plugin-svelte`). Knowing *why* they exist prevents the need to fix violations after the fact.
### Always key `{#each}` blocks
Without a key, Svelte tracks list items by array position. When items are added, removed, or reordered, Svelte patches DOM nodes in-place from the top — it never moves the correct node. Component-local state (counters, animation state, focus) becomes permanently attached to the wrong item. This is a silent data integrity bug, not a crash.
```svelte
<!-- Bad — position-based tracking; reordering silently corrupts local state -->
{#each documents as doc}
<DocumentCard {doc} />
{/each}
<!-- Good — identity-based; each node follows its data through reorders -->
{#each documents as doc (doc.id)}
<DocumentCard {doc} />
{/each}
```
Use `(item.id)` when items have a stable ID. Use the loop index `(i)` only for static lists that will never be reordered. Use `(item)` for primitive lists.
### Use `$derived` for computed values, never `$state` + `$effect`
`$effect` is for *side effects* (DOM calls, network, logging). Using it to assign a computed value introduces a timing problem: `$derived` updates synchronously before the render, while `$effect` runs *after* the render — meaning the component briefly displays a stale value. It also triggers a second reactive pass, doubling the work.
```svelte
<!-- Bad — stale value during render; extra reactive cycle; unclear intent -->
<script>
let fullName = $state('');
$effect(() => {
fullName = `${person.firstName} ${person.lastName}`;
});
</script>
<!-- Good — synchronous, single-pass, intent is obvious -->
<script>
const fullName = $derived(`${person.firstName} ${person.lastName}`);
</script>
```
Use `$derived.by(() => { ... })` when the computation needs multiple statements.
### Use Svelte reactive collections, not plain JS ones
Svelte 5's reactivity tracks object *references*, not mutations. When you call `.set()` on a plain `Map` or `.set()` on a plain `URLSearchParams`, the reference doesn't change — Svelte never notices, and the UI goes silently stale.
`SvelteMap`, `SvelteSet`, and `SvelteURLSearchParams` from `svelte/reactivity` wrap the native classes and hook into Svelte's dependency tracker. Every mutation notifies the reactive graph; every read registers a dependency.
```svelte
<!-- Bad — mutations are invisible to Svelte; derived values never update -->
<script>
const freq = new Map<string, number>();
freq.set('key', 1); // Svelte does not see this
</script>
<!-- Good — mutations are tracked; all dependents re-run correctly -->
<script>
import { SvelteMap } from 'svelte/reactivity';
const freq = new SvelteMap<string, number>();
freq.set('key', 1); // Svelte tracks this
</script>
```
The same applies to `URLSearchParams` in reactive contexts — use `SvelteURLSearchParams`.

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.
@@ -122,9 +158,30 @@ Closes #7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
```
## Code Style Reminders
### Atomic commits
Each commit must do exactly one logical thing. Never bundle multiple unrelated changes into a single commit, even if they are small.
**Wrong** — three changes in one commit:
```
fix(e2e+i18n): add missing DE translation, fix test selectors, fix lang switching
```
**Right** — three separate commits:
```
fix(i18n): add missing person_btn_conversations DE translation
fix(e2e): exclude /persons/new from person link selector
fix(e2e): clear locale cookie when switching back to base language
```
When in doubt, commit more often rather than less.
## Code Style
See [CODESTYLE.md](./CODESTYLE.md) for the full guide: Clean Code (Uncle Bob), DRY/KISS trade-offs, and SOLID principles applied to this stack.
Quick reminders:
- Pure functions over stateful helpers where possible
- No premature abstractions — solve the problem in front of you
- No premature abstractions — KISS beats DRY
- No backwards-compatibility shims for code that has no callers
- Validate at system boundaries only (user input, external APIs)

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>

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,8 +43,8 @@ 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()
@@ -81,7 +81,8 @@ public class DataInitializer {
@Profile("e2e")
public CommandLineRunner initE2EData(PersonRepository personRepo,
DocumentRepository docRepo,
TagRepository tagRepo) {
TagRepository tagRepo,
PasswordEncoder passwordEncoder) {
return args -> {
if (personRepo.count() > 0) {
log.info("E2E seed: Daten bereits vorhanden, überspringe.");
@@ -165,8 +166,21 @@ public class DataInitializer {
.receivers(Set.of(otto))
.build());
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente erstellt.",
personRepo.count(), tagRepo.count(), docRepo.count());
// ── Read-only user (for permissions E2E tests) ───────────────────
// Username: reader / Password: reader123
// Has only READ_ALL — used to assert write controls are absent.
UserGroup leserGroup = 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: {} 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,11 @@ 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));
}
}

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

@@ -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.PersonUpdateDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.service.DocumentService;
@@ -33,11 +34,23 @@ public class PersonController {
return personService.getById(id);
}
@GetMapping("/{id}/correspondents")
public ResponseEntity<List<Person>> getCorrespondents(
@PathVariable UUID id,
@RequestParam(required = false) String q) {
return ResponseEntity.ok(personService.findCorrespondents(id, q));
}
@GetMapping("/{id}/documents")
public List<Document> getPersonDocuments(@PathVariable UUID id) {
return documentService.getDocumentsBySender(id);
}
@GetMapping("/{id}/received-documents")
public List<Document> getPersonReceivedDocuments(@PathVariable UUID id) {
return documentService.getDocumentsByReceiver(id);
}
@PostMapping
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
String firstName = body.get("firstName");
@@ -49,13 +62,14 @@ public class PersonController {
}
@PutMapping("/{id}")
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
String firstName = body.get("firstName");
String lastName = body.get("lastName");
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias")));
dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
return ResponseEntity.ok(personService.updatePerson(id, dto));
}
@PostMapping("/{id}/merge")

View File

@@ -4,7 +4,10 @@ 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;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
@@ -16,8 +19,10 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import lombok.AllArgsConstructor;
@@ -33,13 +38,32 @@ public class UserController {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// Fetch full user object from DB to get latest permissions/groups
AppUser user = userService.findByUsername(authentication.getName());
// Security: Remove password before sending
user.setPassword(null);
return ResponseEntity.ok(user);
}
@PutMapping("users/me")
public ResponseEntity<AppUser> updateProfile(Authentication authentication,
@RequestBody UpdateProfileDTO dto) {
AppUser current = userService.findByUsername(authentication.getName());
AppUser updated = userService.updateProfile(current.getId(), dto);
updated.setPassword(null);
return ResponseEntity.ok(updated);
}
@PostMapping("users/me/password")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void changePassword(Authentication authentication,
@RequestBody ChangePasswordDTO dto) {
AppUser current = userService.findByUsername(authentication.getName());
userService.changePassword(current.getId(), dto);
}
@GetMapping("users/{id}")
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {
AppUser user = userService.getById(id);
user.setPassword(null);
return ResponseEntity.ok(user);
}
@@ -56,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,9 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
@Data
public class ChangePasswordDTO {
private String currentPassword;
private String newPassword;
}

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,13 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
@Data
public class PersonUpdateDTO {
private String firstName;
private String lastName;
private String alias;
private String notes;
private Integer birthYear;
private Integer deathYear;
}

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

@@ -0,0 +1,14 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
import java.time.LocalDate;
@Data
public class UpdateProfileDTO {
private String firstName;
private String lastName;
private LocalDate birthDate;
private String email;
private String contact;
}

View File

@@ -43,6 +43,10 @@ public class DomainException extends RuntimeException {
return new DomainException(code, HttpStatus.CONFLICT, message);
}
public static DomainException badRequest(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.BAD_REQUEST, message);
}
public static DomainException internal(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
}

View File

@@ -21,6 +21,10 @@ public enum ErrorCode {
// --- Users ---
/** A user with the given ID or username does not exist. 404 */
USER_NOT_FOUND,
/** The supplied email address is already used by another account. 409 */
EMAIL_ALREADY_IN_USE,
/** The supplied current password does not match the stored hash. 400 */
WRONG_CURRENT_PASSWORD,
// --- Import ---
/** A mass import is already in progress; only one can run at a time. 409 */
@@ -31,6 +35,8 @@ 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,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */

View File

@@ -10,6 +10,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@@ -36,8 +37,16 @@ public class AppUser {
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; // Wird verschlüsselt gespeichert (BCrypt)
private String firstName;
private String lastName;
private LocalDate birthDate;
@Column(unique = true)
private String email;
@Column(columnDefinition = "TEXT")
private String contact;
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen

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

@@ -28,4 +28,11 @@ public class Person {
// Optional: Aliasse für die Suche (z.B. "Opa Hans")
private String alias;
// Optional: Free-text biographical notes
@Column(columnDefinition = "TEXT")
private String notes;
private Integer birthYear;
private Integer deathYear;
}

View File

@@ -11,4 +11,5 @@ import java.util.UUID;
@Repository
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
Optional<AppUser> findByUsername(String username);
Optional<AppUser> findByEmail(String email);
}

View File

@@ -30,8 +30,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
List<Document> findBySenderId(UUID senderId);
List<Document> findByReceiversId(UUID receiverId);
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();
@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

@@ -28,6 +28,51 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
// --- Correspondent queries ---
@Query(value = """
SELECT p.* FROM persons p
INNER JOIN (
SELECT dr.person_id AS other_id, d.id AS doc_id
FROM documents d
JOIN document_receivers dr ON dr.document_id = d.id
WHERE d.sender_id = :personId
UNION ALL
SELECT d.sender_id AS other_id, d.id AS doc_id
FROM documents d
JOIN document_receivers dr ON dr.document_id = d.id
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
) shared ON shared.other_id = p.id
WHERE p.id != :personId
GROUP BY p.id
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
LIMIT 10
""", nativeQuery = true)
List<Person> findCorrespondents(@Param("personId") UUID personId);
@Query(value = """
SELECT p.* FROM persons p
INNER JOIN (
SELECT dr.person_id AS other_id, d.id AS doc_id
FROM documents d
JOIN document_receivers dr ON dr.document_id = d.id
WHERE d.sender_id = :personId
UNION ALL
SELECT d.sender_id AS other_id, d.id AS doc_id
FROM documents d
JOIN document_receivers dr ON dr.document_id = d.id
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
) shared ON shared.other_id = p.id
WHERE p.id != :personId
AND (LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%'))
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:q,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:q,'%')))
GROUP BY p.id
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
LIMIT 10
""", nativeQuery = true)
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
@Modifying

View File

@@ -29,14 +29,15 @@ import java.util.UUID;
import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
@Service
@RequiredArgsConstructor // Lombok: Erzeugt Constructor für 'final' Felder (Dependency Injection)
@Slf4j // Lombok: Logging
@RequiredArgsConstructor
@Slf4j
public class DocumentService {
private final DocumentRepository documentRepository;
private final PersonService personService;
private final FileService fileService;
private final TagService tagService;
private final DocumentVersionService documentVersionService;
/**
* Lädt eine Datei hoch.
@@ -102,8 +103,10 @@ public class DocumentService {
.filter(s -> !s.isEmpty())
.toList();
}
updateDocumentTags(doc.getId(), tags);
doc = documentRepository.findById(doc.getId()).orElseThrow();
UUID savedId = doc.getId();
updateDocumentTags(savedId, tags);
doc = documentRepository.findById(savedId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found after save: " + savedId));
// Sender
if (dto.getSenderId() != null) {
@@ -123,7 +126,9 @@ public class DocumentService {
doc.setStatus(DocumentStatus.UPLOADED);
}
return documentRepository.save(doc);
Document finalDoc = documentRepository.save(doc);
documentVersionService.recordVersion(finalDoc);
return finalDoc;
}
@Transactional
@@ -176,11 +181,14 @@ public class DocumentService {
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) {
Document doc = documentRepository.findById(docId).orElseThrow();
Document doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
Set<Tag> newTags = new HashSet<>();
@@ -217,12 +225,11 @@ public class DocumentService {
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID reciever, List<String> tags) {
log.info("Tags", tags);
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags) {
Specification<Document> spec = Specification.where(hasText(text))
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(reciever))
.and(hasReceiver(receiver))
.and(hasTags(tags));
// Immer sortiert nach Datum
@@ -250,10 +257,18 @@ 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);
}
public List<Document> getDocumentsByReceiver(UUID receiverId) {
return documentRepository.findByReceiversId(receiverId);
}
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
LocalDate dateTo = (to != null) ? to : LocalDate.now();

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

@@ -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 java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.HttpStatus;
@@ -30,6 +31,13 @@ public class PersonService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
}
public List<Person> findCorrespondents(UUID personId, String q) {
if (q != null && !q.isBlank()) {
return personRepository.findCorrespondentsWithFilter(personId, q);
}
return personRepository.findCorrespondents(personId);
}
public List<Person> getAllById(List<UUID> ids) {
return personRepository.findAllById(ids);
}
@@ -58,12 +66,18 @@ public class PersonService {
}
@Transactional
public Person updatePerson(UUID id, String firstName, String lastName, String alias) {
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
}
Person person = personRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
person.setFirstName(firstName);
person.setLastName(lastName);
person.setAlias(alias == null || alias.isBlank() ? null : alias.trim());
person.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
return personRepository.save(person);
}

View File

@@ -3,7 +3,10 @@ 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;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -30,49 +33,121 @@ public class UserService {
private final UserGroupRepository groupRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Versuche neuen User anzulegen: {}", request.getUsername());
@Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getUsername());
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
List<UserGroup> foundGroups = groupRepository.findAllById(request.getGroupIds());
groups.addAll(foundGroups);
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
}
Optional<AppUser> existingUser = userRepository.findByUsername(request.getUsername());
AppUser user;
if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getUsername());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
} else {
log.info("Creating new user: {}", request.getUsername());
user = AppUser.builder()
.username(request.getUsername())
.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();
}
return userRepository.save(user);
}
log.info("GroupsIds {}", groups.toString());
log.info("Groupds in DB {}", groupRepository.findAll().toString());
Optional<AppUser> dbUser = userRepository.findByUsername(request.getUsername());
AppUser user;
if (dbUser.isPresent()) {
log.info("Found user in DB. Will update.");
user = dbUser.get().updateFromRequest(request, passwordEncoder, groups);
} else {
log.info("Creating new user.");
user = AppUser.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
.enabled(true)
.build();
}
log.info("Saving new user {}", user.toString());
return userRepository.save(user);
}
@Transactional
public void deleteUser(UUID userId) {
log.info("Delete user {}", userId);
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for id %s", userId)));
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
userRepository.delete(user);
}
public AppUser getById(UUID id) {
return userRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
}
@Transactional
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
AppUser user = getById(userId);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
if (!existing.getId().equals(userId)) {
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());
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);
if (!passwordEncoder.matches(dto.getCurrentPassword(), user.getPassword())) {
throw DomainException.badRequest(ErrorCode.WRONG_CURRENT_PASSWORD,
"Das aktuelle Passwort ist falsch");
}
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
userRepository.save(user);
}
public AppUser findByUsername(String username) {
return userRepository.findByUsername(username).orElseThrow(
() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for username %s", username)));
return userRepository.findByUsername(username)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for username: " + username));
}
public List<AppUser> getAllUsers() {

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 @@
ALTER TABLE persons ADD COLUMN IF NOT EXISTS notes TEXT;

View File

@@ -0,0 +1,2 @@
ALTER TABLE persons ADD COLUMN IF NOT EXISTS birth_year INTEGER;
ALTER TABLE persons ADD COLUMN IF NOT EXISTS death_year INTEGER;

View File

@@ -0,0 +1,5 @@
ALTER TABLE users ADD COLUMN first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN last_name VARCHAR(100);
ALTER TABLE users ADD COLUMN birth_date DATE;
ALTER TABLE users ADD COLUMN contact TEXT;
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);

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,61 @@
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));
}
}

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,52 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
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.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
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.Collections;
import java.util.UUID;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PersonController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class PersonControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean PersonService personService;
@MockitoBean DocumentService documentService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/persons/{id}/received-documents ─────────────────────────────
@Test
void getReceivedDocuments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/persons/{id}/received-documents", UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getReceivedDocuments_returns200_whenAuthenticated() throws Exception {
UUID personId = UUID.randomUUID();
when(documentService.getDocumentsByReceiver(personId)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
.andExpect(status().isOk());
}
}

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;
@@ -29,6 +30,7 @@ class DocumentServiceTest {
@Mock PersonService personService;
@Mock FileService fileService;
@Mock TagService tagService;
@Mock DocumentVersionService documentVersionService;
@InjectMocks DocumentService documentService;
// ─── getDocumentById ──────────────────────────────────────────────────────
@@ -121,4 +123,48 @@ class DocumentServiceTest {
assertThat(result).isEqualTo(saved);
verify(documentRepository).save(any());
}
// ─── getDocumentsByReceiver ───────────────────────────────────────────────
@Test
void getDocumentsByReceiver_returnsDocumentsWherePersonIsReceiver() {
UUID receiverId = UUID.randomUUID();
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
when(documentRepository.findByReceiversId(receiverId)).thenReturn(List.of(doc));
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
}
// ─── 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));
}
}

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

@@ -5,10 +5,12 @@ 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.PersonUpdateDTO;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -83,6 +85,115 @@ class PersonServiceTest {
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
}
// ─── updatePerson (notes) ────────────────────────────────────────────────
@Test
void updatePerson_persistsNotes() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes("Some notes here.");
Person result = personService.updatePerson(id, dto);
assertThat(result.getNotes()).isEqualTo("Some notes here.");
}
@Test
void updatePerson_clearsNotes_whenBlank() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").notes("old notes").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes(" ");
Person result = personService.updatePerson(id, dto);
assertThat(result.getNotes()).isNull();
}
// ─── updatePerson (birth/death years) ────────────────────────────────────
@Test
void updatePerson_persistsBirthAndDeathYear() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isEqualTo(1965);
}
@Test
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_allowsSameYear() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1900);
assertThat(result.getDeathYear()).isEqualTo(1900);
}
// ─── findCorrespondents ──────────────────────────────────────────────────
@Test
void findCorrespondents_delegatesToRepository_withoutFilter() {
UUID personId = UUID.randomUUID();
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build());
when(personRepository.findCorrespondents(personId)).thenReturn(expected);
assertThat(personService.findCorrespondents(personId, null)).isEqualTo(expected);
verify(personRepository).findCorrespondents(personId);
verify(personRepository, never()).findCorrespondentsWithFilter(any(), any());
}
@Test
void findCorrespondents_delegatesToRepository_withFilter() {
UUID personId = UUID.randomUUID();
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build());
when(personRepository.findCorrespondentsWithFilter(personId, "Anna")).thenReturn(expected);
assertThat(personService.findCorrespondents(personId, "Anna")).isEqualTo(expected);
verify(personRepository).findCorrespondentsWithFilter(personId, "Anna");
verify(personRepository, never()).findCorrespondents(any());
}
@Test
void findCorrespondents_delegatesToRepository_withBlankFilter() {
UUID personId = UUID.randomUUID();
when(personRepository.findCorrespondents(personId)).thenReturn(List.of());
personService.findCorrespondents(personId, " ");
verify(personRepository).findCorrespondents(personId);
verify(personRepository, never()).findCorrespondentsWithFilter(any(), any());
}
// ─── mergePersons ─────────────────────────────────────────────────────────
@Test

View File

@@ -5,7 +5,9 @@ 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.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.repository.AppUserRepository;
@@ -20,6 +22,7 @@ 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.*;
import static org.mockito.ArgumentMatchers.argThat;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@@ -109,6 +112,110 @@ class UserServiceTest {
verify(userRepository, times(1)).save(existing);
}
// ─── getById ──────────────────────────────────────────────────────────────
@Test
void getById_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(userRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.getById(id))
.isInstanceOf(DomainException.class);
}
@Test
void getById_returnsUser_whenFound() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
assertThat(userService.getById(id)).isEqualTo(user);
}
// ─── updateProfile ────────────────────────────────────────────────────────
@Test
void updateProfile_updatesFields() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.empty());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setFirstName("Max"); dto.setLastName("Müller"); dto.setEmail("max@example.com");
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getFirstName()).isEqualTo("Max");
assertThat(result.getLastName()).isEqualTo("Müller");
assertThat(result.getEmail()).isEqualTo("max@example.com");
}
@Test
void updateProfile_throwsConflict_whenEmailTakenByAnotherUser() {
UUID id = UUID.randomUUID();
UUID otherId = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail("taken@example.com");
assertThatThrownBy(() -> userService.updateProfile(id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("E-Mail");
}
@Test
void updateProfile_allowsSameEmailForSameUser() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").email("max@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail("max@example.com");
dto.setFirstName("Max");
assertThat(userService.updateProfile(id, dto).getEmail()).isEqualTo("max@example.com");
}
// ─── changePassword ───────────────────────────────────────────────────────
@Test
void changePassword_throwsBadRequest_whenCurrentPasswordWrong() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrong", "hashed")).thenReturn(false);
ChangePasswordDTO dto = new ChangePasswordDTO();
dto.setCurrentPassword("wrong"); dto.setNewPassword("newpass");
assertThatThrownBy(() -> userService.changePassword(id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("Passwort");
}
@Test
void changePassword_updatesHash_whenCurrentPasswordCorrect() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(passwordEncoder.matches("correct", "hashed")).thenReturn(true);
when(passwordEncoder.encode("newpass")).thenReturn("newHash");
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
ChangePasswordDTO dto = new ChangePasswordDTO();
dto.setCurrentPassword("correct"); dto.setNewPassword("newpass");
userService.changePassword(id, dto);
verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword())));
}
// ─── getGroupById ─────────────────────────────────────────────────────────
@Test

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,16 @@ services:
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
S3_REGION: us-east-1
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=
```

1
frontend/.gitignore vendored
View File

@@ -28,3 +28,4 @@ src/lib/paraglide
# Generated OpenAPI types — regenerate with: npm run generate:api
# (committed as a stub; overwritten by the real spec after generation)
# src/lib/generated/api.ts
src/lib/paraglide_bak*

View File

@@ -0,0 +1 @@
npm test

View File

@@ -7,3 +7,12 @@ bun.lockb
# Miscellaneous
/static/
# Generated files
/src/lib/generated/
/src/lib/paraglide/
/src/lib/paraglide_bak*/
# Test artifacts
/test-results/
/e2e/.auth/

View File

@@ -3,9 +3,7 @@
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-tailwindcss"
],
"plugins": ["prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",

View File

@@ -0,0 +1,25 @@
{
"cookies": [
{
"name": "PARAGLIDE_LOCALE",
"value": "de",
"domain": "localhost",
"path": "/",
"expires": 1808565334.192108,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "auth_token",
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
"domain": "localhost",
"path": "/",
"expires": 1774091734.449243,
"httpOnly": true,
"secure": false,
"sameSite": "Strict"
}
],
"origins": []
}

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

@@ -0,0 +1,218 @@
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' });
});
});

View File

@@ -9,11 +9,11 @@ const authFile = path.join(__dirname, '.auth/user.json');
* Logs in once and saves the session cookie so all E2E tests can reuse it.
* Configure credentials via environment variables:
* E2E_USERNAME (default: admin)
* E2E_PASSWORD (default: admin)
* E2E_PASSWORD (default: admin123)
*/
setup('authenticate', async ({ page }) => {
const username = process.env.E2E_USERNAME ?? 'admin';
const password = process.env.E2E_PASSWORD ?? 'admin';
const password = process.env.E2E_PASSWORD ?? 'admin123';
await page.goto('/login');
await page.getByLabel('Benutzername').fill(username);

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,5 @@
import { test, expect } from '@playwright/test';
import path from 'path';
/**
* Document management E2E tests.
@@ -80,6 +81,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 +143,78 @@ 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;
test.beforeAll(async ({ browser }) => {
// Create a document and upload the PDF fixture so later tests have a
// real file attached. Runs once for the whole describe block.
const ctx = await browser.newContext();
const p = await ctx.newPage();
await p.goto('/documents/new');
await p.waitForSelector('[data-hydrated]');
await p.getByLabel('Titel').fill('E2E PDF Viewer Test');
await p.getByRole('button', { name: /Speichern/i }).click();
await p.waitForURL(/\/documents\/[^/]+$/);
// Upload the PDF on the edit page
const href = p.url().replace(/\/$/, '');
pdfDocHref = href;
await p.goto(`${href}/edit`);
await p.waitForSelector('[data-hydrated]');
await p.locator('input[type="file"][name="file"]').setInputFiles(PDF_FIXTURE);
await p.getByRole('button', { name: /Speichern/i }).click();
await p.waitForURL(/\/documents\/[^/]+$/);
await ctx.close();
});
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('non-PDF attachment renders as an img element, not canvas', async ({ page }) => {
// The seed document "Urlaubspostkarte Ostsee" has a .jpg original filename.
// Navigate to it and confirm an <img> is used (no canvas, no iframe).
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.goto('/?q=Urlaubspostkarte');
const link = page.getByRole('link', { name: /Urlaubspostkarte/i }).first();
const href = await link.getAttribute('href');
await page.goto(href!);
await page.waitForSelector('[data-hydrated]');
// No canvas — this is an image document
await expect(page.locator('canvas')).not.toBeAttached();
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.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

@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test';
export async function login(
page: Page,
username = process.env.E2E_USERNAME ?? 'admin',
password = process.env.E2E_PASSWORD ?? 'admin'
password = process.env.E2E_PASSWORD ?? 'admin123'
) {
await page.goto('/login');
await page.getByLabel('Benutzername').fill(username);

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

@@ -3,16 +3,24 @@ import { test, expect } from '@playwright/test';
test.describe('Language selector', () => {
test('shows DE, EN, ES buttons in the header', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })).toBeVisible();
await expect(page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })).toBeVisible();
await expect(page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })).toBeVisible();
await expect(
page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })
).toBeVisible();
await expect(
page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })
).toBeVisible();
await expect(
page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })
).toBeVisible();
});
test('switching to EN translates the navigation', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible();
await expect(
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
).toBeVisible();
await expect(page.getByRole('navigation').getByRole('link', { name: 'Persons' })).toBeVisible();
});
@@ -21,15 +29,27 @@ test.describe('Language selector', () => {
await page.waitForSelector('[data-hydrated]');
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
await page.goto('/persons');
await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible();
await expect(
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
).toBeVisible();
});
test('switching back to DE restores German', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
await expect(
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
).toBeVisible();
await page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }).click();
await expect(page.getByRole('navigation').getByRole('link', { name: 'Dokumente' })).toBeVisible();
// In headless Chromium, cookie deletion via document.cookie can be unreliable.
// Delete the PARAGLIDE_LOCALE cookie directly so the next navigation defaults to DE.
await page.context().clearCookies({ name: 'PARAGLIDE_LOCALE' });
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await expect(
page.getByRole('navigation').getByRole('link', { name: 'Dokumente' })
).toBeVisible();
});
test('active language button is visually highlighted', async ({ page }) => {

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

@@ -0,0 +1,87 @@
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 }) => {
await page.goto('/');
await expect(page.getByRole('link', { name: /Neues Dokument/i })).toBeVisible();
});
test('admin user sees Neue Person link on persons page', async ({ page }) => {
await page.goto('/persons');
await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible();
});
test('admin user can navigate to /persons/new', async ({ page }) => {
await page.goto('/persons/new');
await expect(page).toHaveURL('/persons/new');
await expect(page.getByLabel('Vorname')).toBeVisible();
});
test('admin user can navigate to /documents/new', async ({ page }) => {
await page.goto('/documents/new');
await expect(page).toHaveURL('/documents/new');
});
test('admin user sees edit button on person detail page', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
await firstPerson.click();
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

@@ -52,6 +52,29 @@ test.describe('Person detail', () => {
await expect(page.getByLabel('Vorname')).not.toBeVisible();
}
});
test('birth and death year fields appear in edit mode and save correctly', 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]');
const editBtn = page.getByRole('button', { name: /Bearbeiten/i });
await editBtn.click();
await expect(page.getByLabel(/Geburtsjahr/i)).toBeVisible();
await expect(page.getByLabel(/Todesjahr/i)).toBeVisible();
await page.getByLabel(/Geburtsjahr/i).fill('1890');
await page.getByLabel(/Todesjahr/i).fill('1965');
await page.getByRole('button', { name: /Speichern/i }).click();
// After saving, the years should be shown in view mode
await expect(page.getByText('* 1890')).toBeVisible();
await expect(page.getByText('† 1965')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-birth-death-years.png' });
});
});
test.describe('New person', () => {
@@ -72,21 +95,109 @@ 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('sort toggle changes the button label when person has documents', async ({ page }) => {
test('each section has its own sort toggle that works independently', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
await page.waitForSelector('[data-hydrated]');
const sortBtn = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i });
if (await sortBtn.isVisible()) {
await expect(sortBtn).toContainText('Neueste zuerst');
await sortBtn.click();
await expect(sortBtn).toContainText('Älteste zuerst');
await sortBtn.click();
await expect(sortBtn).toContainText('Neueste zuerst');
await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' });
// Find sort buttons — there may be 0, 1 or 2 depending on whether sections have >1 doc
const sortBtns = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i });
const btnCount = await sortBtns.count();
if (btnCount >= 1) {
const firstBtn = sortBtns.first();
await expect(firstBtn).toContainText('Neueste zuerst');
await firstBtn.click();
await expect(firstBtn).toContainText('Älteste zuerst');
await firstBtn.click();
await expect(firstBtn).toContainText('Neueste zuerst');
}
if (btnCount >= 2) {
// Second sort button toggles independently
const secondBtn = sortBtns.nth(1);
await expect(secondBtn).toContainText('Neueste zuerst');
await secondBtn.click();
await expect(secondBtn).toContainText('Älteste zuerst');
// First button should be unaffected
await expect(sortBtns.first()).toContainText('Neueste zuerst');
}
await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' });
});
});
test.describe('Person detail — sent and received documents', () => {
test('shows both sent and received document sections', 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('heading', { name: /Gesendete Dokumente/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /Empfangene Dokumente/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-sent-received.png' });
});
test('shows year range next to document count when documents have dates', async ({ page }) => {
// Navigate to the first person who has documents with dates
await page.goto('/persons');
const personLinks = page.locator('a[href^="/persons/"]:not([href="/persons/new"])');
const count = await personLinks.count();
for (let i = 0; i < count; i++) {
await page.goto('/persons');
await personLinks.nth(i).click();
await page.waitForSelector('[data-hydrated]');
// Check if either section heading has a year range (4 digits)
const sentHeading = page.getByRole('heading', { name: /Gesendete Dokumente/i }).locator('..');
const hasYearRange = await sentHeading.locator('span').filter({ hasText: /\d{4}/ }).count();
if (hasYearRange > 0) {
await expect(
sentHeading.locator('span').filter({ hasText: /\d{4}/ }).first()
).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-year-range.png' });
return;
}
}
// If no person has dated documents, the test is a no-op (year range is optional)
});
});
test.describe('Person detail — conversations link', () => {
test('co-correspondent chips link to conversations pre-filled with both persons', async ({
page
}) => {
await page.goto('/persons');
const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
const href = await firstLink.getAttribute('href');
const personId = href!.split('/persons/')[1];
await firstLink.click();
await page.waitForSelector('[data-hydrated]');
// Co-correspondent chips link to /conversations?senderId=X&receiverId=Y
const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first();
if ((await chip.count()) > 0) {
const chipHref = await chip.getAttribute('href');
expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/);
}
});
});
@@ -115,3 +226,87 @@ test.describe('Conversations', () => {
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
});
});
test.describe('Conversations — enhancements', () => {
// Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
// Navigate directly by URL so the test doesn't rely on typeahead interaction
async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
// Resolve person IDs from the persons list
await page.goto('/persons');
const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
const hansHref = await hansLink.getAttribute('href');
const hansId = hansHref!.split('/').pop()!;
const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
const annaHref = await annaLink.getAttribute('href');
const annaId = annaHref!.split('/').pop()!;
await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
await page.waitForURL(/senderId=/);
}
test('shows document count and year range summary when both persons are selected', async ({
page
}) => {
await loadHansAnnaConversation(page);
// Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 19231965
await expect(page.getByTestId('conv-summary')).toContainText('2');
await expect(page.getByTestId('conv-summary')).toContainText('1923');
await expect(page.getByTestId('conv-summary')).toContainText('1965');
await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
});
test('shows year dividers between documents from different years', async ({ page }) => {
await loadHansAnnaConversation(page);
// Expect at least two year dividers (1923 and 1965)
await expect(page.getByTestId('year-divider').first()).toBeVisible();
const dividers = page.getByTestId('year-divider');
const texts = await dividers.allTextContents();
expect(texts.some((t) => t.includes('1923'))).toBe(true);
expect(texts.some((t) => t.includes('1965'))).toBe(true);
await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
});
test('swap button switches sender and receiver and reloads', async ({ page }) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const originalSenderId = url.searchParams.get('senderId')!;
const originalReceiverId = url.searchParams.get('receiverId')!;
await page.getByTestId('conv-swap-btn').click();
// 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);
expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId);
await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' });
});
test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({
page
}) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const senderId = url.searchParams.get('senderId')!;
const receiverId = url.searchParams.get('receiverId')!;
const link = page.getByTestId('conv-new-doc-link');
await expect(link).toBeVisible();
const href = await link.getAttribute('href');
expect(href).toContain(`senderId=${senderId}`);
expect(href).toContain(`receiverId=${receiverId}`);
await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' });
});
test('does not show swap button or new document link when only one person is selected', async ({
page
}) => {
await page.goto('/conversations');
await page.waitForURL('/conversations');
await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible();
await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible();
});
});

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

@@ -21,16 +21,17 @@ export default defineConfig(
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
"no-undef": 'off' }
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
// This rule is designed for Svelte 5's own routing system using resolve().
// In SvelteKit, <a href> and goto() from $app/navigation are the correct patterns — resolve() is not needed.
'svelte/no-navigation-without-resolve': 'off'
}
},
{
files: [
'**/*.svelte',
'**/*.svelte.ts',
'**/*.svelte.js'
],
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"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.",
@@ -11,13 +10,11 @@
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
"error_validation_error": "Die Eingabe ist ungültig.",
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
"nav_documents": "Dokumente",
"nav_persons": "Personen",
"nav_conversations": "Konversationen",
"nav_admin": "Admin",
"nav_logout": "Abmelden",
"btn_save": "Speichern",
"btn_cancel": "Abbrechen",
"btn_edit": "Bearbeiten",
@@ -26,7 +23,6 @@
"btn_back_to_overview": "Zurück zur Übersicht",
"btn_back": "Zurück",
"btn_back_to_document": "Zurück zum Dokument",
"form_label_first_name": "Vorname",
"form_label_last_name": "Nachname",
"form_label_alias": "Rufname / Alias",
@@ -47,12 +43,10 @@
"form_label_archive_location": "Aufbewahrungsort",
"form_placeholder_archive_location": "z.B. Schrank 3, Mappe B",
"form_helper_archive_location": "Wo befindet sich das Originaldokument?",
"login_heading": "Anmelden",
"login_label_username": "Benutzername",
"login_label_password": "Passwort",
"login_btn_submit": "Anmelden",
"docs_search_placeholder": "Suche in Titel, Inhalt, Ort...",
"docs_btn_filter": "Filter",
"docs_btn_reset_title": "Filter zurücksetzen",
@@ -68,7 +62,6 @@
"docs_list_from": "Von",
"docs_list_to": "An",
"docs_list_unknown": "Unbekannt",
"doc_section_who_when": "Wer & Wann",
"doc_section_description": "Beschreibung",
"doc_section_file": "Datei",
@@ -79,7 +72,6 @@
"doc_current_file_label": "Aktuelle Datei:",
"doc_new_heading": "Neues Dokument",
"doc_edit_heading": "Bearbeiten",
"doc_section_details": "Details",
"doc_label_document_date": "Dokumentendatum",
"doc_label_creation_location": "Erstellungsort",
@@ -92,17 +84,14 @@
"doc_loading": "Lade Dokument...",
"doc_download_link": "Direkter Download versuchen",
"doc_no_scan": "Kein Scan vorhanden",
"persons_heading": "Personenverzeichnis",
"persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.",
"persons_btn_new": "Neue Person",
"persons_search_placeholder": "Namen suchen...",
"persons_empty_heading": "Keine Personen gefunden.",
"persons_empty_text": "Versuchen Sie einen anderen Suchbegriff.",
"persons_new_heading": "Neue Person",
"persons_section_details": "Angaben zur Person",
"person_edit_heading": "Person bearbeiten",
"person_label_full_name": "Voller Name",
"person_merge_heading": "Person zusammenführen",
@@ -111,9 +100,21 @@
"person_btn_merge": "Zusammenführen",
"person_btn_merge_confirm": "Ja, zusammenführen",
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
"person_label_notes": "Notizen",
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
"person_label_birth_year": "Geburtsjahr",
"person_label_death_year": "Todesjahr",
"person_placeholder_year": "z.B. 1923",
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
"person_docs_heading": "Gesendete Dokumente",
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
"person_received_docs_heading": "Empfangene Dokumente",
"person_no_received_docs": "Diese Person ist noch nicht als Empfänger verknüpft.",
"person_role_sender": "Gesendet",
"person_role_receiver": "Empfangen",
"person_co_correspondents_heading": "Häufige Korrespondenten",
"person_show_more": "+ {count} weitere anzeigen",
"conv_heading": "Konversationen",
"conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.",
"conv_label_person_a": "Person A (Absender)",
@@ -127,7 +128,9 @@
"conv_empty_text": "Die Korrespondenz wird hier angezeigt.",
"conv_no_results_heading": "Keine Dokumente gefunden.",
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
"conv_swap_btn": "Personen tauschen",
"conv_summary": "{count} Dokumente · {yearFrom}{yearTo}",
"conv_new_doc_link": "Neues Dokument in dieser Korrespondenz",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer",
"admin_tab_groups": "Gruppen",
@@ -156,7 +159,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",
@@ -164,9 +174,7 @@
"doc_preview_iframe_title": "Dokumentvorschau",
"doc_image_alt": "Original-Scan",
"doc_no_date": "Kein Datum",
"person_merge_will_be_deleted": "wird gelöscht.",
"comp_typeahead_placeholder": "Namen tippen...",
"comp_typeahead_loading": "Suche...",
"comp_multiselect_placeholder": "Namen tippen...",
@@ -175,5 +183,62 @@
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
"comp_taginput_remove": "Schlagwort entfernen",
"comp_taginput_create_hint": "Enter drücken um Schlagwort zu erstellen."
"comp_taginput_create_hint": "Enter drücken um Schlagwort zu erstellen.",
"error_email_already_in_use": "Diese E-Mail-Adresse wird bereits von einem anderen Konto verwendet.",
"error_wrong_current_password": "Das aktuelle Passwort ist falsch.",
"nav_profile": "Profil",
"profile_heading": "Mein Profil",
"profile_section_personal": "Persönliche Daten",
"profile_label_first_name": "Vorname",
"profile_label_last_name": "Nachname",
"profile_label_birth_date": "Geburtsdatum",
"profile_label_email": "E-Mail-Adresse",
"profile_label_contact": "Kontaktdaten",
"profile_contact_placeholder": "Telefon, Adresse oder sonstige Hinweise...",
"profile_section_password": "Passwort ändern",
"profile_label_current_password": "Aktuelles Passwort",
"profile_label_new_password": "Neues Passwort",
"profile_label_new_password_confirm": "Neues Passwort (Wiederholung)",
"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",
"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.",
"comp_expandable_show_more": "Mehr anzeigen",
"comp_expandable_show_less": "Weniger anzeigen"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"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.",
@@ -11,13 +10,11 @@
"error_forbidden": "You do not have permission for this action.",
"error_validation_error": "The input is invalid.",
"error_internal_error": "An unexpected error occurred.",
"nav_documents": "Documents",
"nav_persons": "Persons",
"nav_conversations": "Conversations",
"nav_admin": "Admin",
"nav_logout": "Sign out",
"btn_save": "Save",
"btn_cancel": "Cancel",
"btn_edit": "Edit",
@@ -26,7 +23,6 @@
"btn_back_to_overview": "Back to overview",
"btn_back": "Back",
"btn_back_to_document": "Back to document",
"form_label_first_name": "First name",
"form_label_last_name": "Last name",
"form_label_alias": "Nickname / Alias",
@@ -47,12 +43,10 @@
"form_label_archive_location": "Storage location",
"form_placeholder_archive_location": "e.g. Cabinet 3, Folder B",
"form_helper_archive_location": "Where is the original document stored?",
"login_heading": "Sign in",
"login_label_username": "Username",
"login_label_password": "Password",
"login_btn_submit": "Sign in",
"docs_search_placeholder": "Search in title, content, location...",
"docs_btn_filter": "Filter",
"docs_btn_reset_title": "Reset filter",
@@ -68,7 +62,6 @@
"docs_list_from": "From",
"docs_list_to": "To",
"docs_list_unknown": "Unknown",
"doc_section_who_when": "Who & When",
"doc_section_description": "Description",
"doc_section_file": "File",
@@ -79,7 +72,6 @@
"doc_current_file_label": "Current file:",
"doc_new_heading": "New document",
"doc_edit_heading": "Edit",
"doc_section_details": "Details",
"doc_label_document_date": "Document date",
"doc_label_creation_location": "Place of creation",
@@ -92,17 +84,14 @@
"doc_loading": "Loading document...",
"doc_download_link": "Try direct download",
"doc_no_scan": "No scan available",
"persons_heading": "Person directory",
"persons_subtitle": "Browse the index of all recorded persons in the family archive.",
"persons_btn_new": "New person",
"persons_search_placeholder": "Search names...",
"persons_empty_heading": "No persons found.",
"persons_empty_text": "Try a different search term.",
"persons_new_heading": "New person",
"persons_section_details": "Person details",
"person_edit_heading": "Edit person",
"person_label_full_name": "Full name",
"person_merge_heading": "Merge person",
@@ -111,9 +100,21 @@
"person_btn_merge": "Merge",
"person_btn_merge_confirm": "Yes, merge",
"person_merge_warning": "Warning: This action cannot be undone.",
"person_label_notes": "Notes",
"person_placeholder_notes": "Biographical notes, remarks…",
"person_label_birth_year": "Birth year",
"person_label_death_year": "Death year",
"person_placeholder_year": "e.g. 1923",
"person_year_error": "Please enter a four-digit year",
"person_years_error_order": "Birth year must be before death year",
"person_docs_heading": "Sent documents",
"person_no_docs": "This person has not yet been linked as a sender.",
"person_received_docs_heading": "Received documents",
"person_no_received_docs": "This person has not yet been linked as a receiver.",
"person_role_sender": "Sent",
"person_role_receiver": "Received",
"person_co_correspondents_heading": "Frequent correspondents",
"person_show_more": "+ {count} more",
"conv_heading": "Conversations",
"conv_subtitle": "Follow the correspondence between two persons chronologically.",
"conv_label_person_a": "Person A (Sender)",
@@ -127,7 +128,9 @@
"conv_empty_text": "The correspondence will be shown here.",
"conv_no_results_heading": "No documents found.",
"conv_no_results_text": "Try adjusting the time period.",
"conv_swap_btn": "Swap persons",
"conv_summary": "{count} documents · {yearFrom}{yearTo}",
"conv_new_doc_link": "New document in this correspondence",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Users",
"admin_tab_groups": "Groups",
@@ -156,7 +159,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}",
@@ -164,9 +174,7 @@
"doc_preview_iframe_title": "Document Preview",
"doc_image_alt": "Original scan",
"doc_no_date": "No date",
"person_merge_will_be_deleted": "will be deleted.",
"comp_typeahead_placeholder": "Type a name...",
"comp_typeahead_loading": "Searching...",
"comp_multiselect_placeholder": "Type a name...",
@@ -175,5 +183,62 @@
"comp_taginput_placeholder_create": "Add tags...",
"comp_taginput_placeholder_filter": "Filter by tags...",
"comp_taginput_remove": "Remove tag",
"comp_taginput_create_hint": "Press Enter to create tag."
"comp_taginput_create_hint": "Press Enter to create tag.",
"error_email_already_in_use": "This email address is already used by another account.",
"error_wrong_current_password": "The current password is incorrect.",
"nav_profile": "Profile",
"profile_heading": "My Profile",
"profile_section_personal": "Personal Information",
"profile_label_first_name": "First name",
"profile_label_last_name": "Last name",
"profile_label_birth_date": "Date of birth",
"profile_label_email": "Email address",
"profile_label_contact": "Contact details",
"profile_contact_placeholder": "Phone, address or other notes...",
"profile_section_password": "Change password",
"profile_label_current_password": "Current password",
"profile_label_new_password": "New password",
"profile_label_new_password_confirm": "New password (repeat)",
"profile_password_mismatch": "The new passwords do not match.",
"profile_saved": "Saved.",
"profile_password_changed": "Password changed successfully.",
"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.",
"comp_expandable_show_more": "Show more",
"comp_expandable_show_less": "Show less"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"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.",
@@ -11,13 +10,11 @@
"error_forbidden": "No tiene permiso para realizar esta acción.",
"error_validation_error": "La entrada no es válida.",
"error_internal_error": "Se ha producido un error inesperado.",
"nav_documents": "Documentos",
"nav_persons": "Personas",
"nav_conversations": "Conversaciones",
"nav_admin": "Admin",
"nav_logout": "Cerrar sesión",
"btn_save": "Guardar",
"btn_cancel": "Cancelar",
"btn_edit": "Editar",
@@ -26,7 +23,6 @@
"btn_back_to_overview": "Volver al resumen",
"btn_back": "Volver",
"btn_back_to_document": "Volver al documento",
"form_label_first_name": "Nombre",
"form_label_last_name": "Apellido",
"form_label_alias": "Apodo / Alias",
@@ -47,12 +43,10 @@
"form_label_archive_location": "Ubicación de almacenamiento",
"form_placeholder_archive_location": "p.ej. Armario 3, Carpeta B",
"form_helper_archive_location": "¿Dónde se encuentra el documento original?",
"login_heading": "Iniciar sesión",
"login_label_username": "Usuario",
"login_label_password": "Contraseña",
"login_btn_submit": "Iniciar sesión",
"docs_search_placeholder": "Buscar en título, contenido, lugar...",
"docs_btn_filter": "Filtrar",
"docs_btn_reset_title": "Restablecer filtro",
@@ -68,7 +62,6 @@
"docs_list_from": "De",
"docs_list_to": "Para",
"docs_list_unknown": "Desconocido",
"doc_section_who_when": "Quién & Cuándo",
"doc_section_description": "Descripción",
"doc_section_file": "Archivo",
@@ -79,7 +72,6 @@
"doc_current_file_label": "Archivo actual:",
"doc_new_heading": "Nuevo documento",
"doc_edit_heading": "Editar",
"doc_section_details": "Detalles",
"doc_label_document_date": "Fecha del documento",
"doc_label_creation_location": "Lugar de creación",
@@ -92,17 +84,14 @@
"doc_loading": "Cargando documento...",
"doc_download_link": "Intentar descarga directa",
"doc_no_scan": "No hay escaneo disponible",
"persons_heading": "Directorio de personas",
"persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.",
"persons_btn_new": "Nueva persona",
"persons_search_placeholder": "Buscar nombres...",
"persons_empty_heading": "No se encontraron personas.",
"persons_empty_text": "Pruebe con otro término de búsqueda.",
"persons_new_heading": "Nueva persona",
"persons_section_details": "Datos de la persona",
"person_edit_heading": "Editar persona",
"person_label_full_name": "Nombre completo",
"person_merge_heading": "Fusionar persona",
@@ -111,9 +100,21 @@
"person_btn_merge": "Fusionar",
"person_btn_merge_confirm": "Sí, fusionar",
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
"person_label_notes": "Notas",
"person_placeholder_notes": "Notas biográficas, observaciones…",
"person_label_birth_year": "Año de nacimiento",
"person_label_death_year": "Año de fallecimiento",
"person_placeholder_year": "p.ej. 1923",
"person_year_error": "Introduzca un año de cuatro dígitos",
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
"person_docs_heading": "Documentos enviados",
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
"person_received_docs_heading": "Documentos recibidos",
"person_no_received_docs": "Esta persona aún no está vinculada como receptor.",
"person_role_sender": "Enviado",
"person_role_receiver": "Recibido",
"person_co_correspondents_heading": "Corresponsales frecuentes",
"person_show_more": "+ {count} más",
"conv_heading": "Conversaciones",
"conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.",
"conv_label_person_a": "Persona A (Remitente)",
@@ -127,7 +128,9 @@
"conv_empty_text": "La correspondencia se mostrará aquí.",
"conv_no_results_heading": "No se encontraron documentos.",
"conv_no_results_text": "Intente ajustar el período de tiempo.",
"conv_swap_btn": "Intercambiar personas",
"conv_summary": "{count} documentos · {yearFrom}{yearTo}",
"conv_new_doc_link": "Nuevo documento en esta correspondencia",
"admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios",
"admin_tab_groups": "Grupos",
@@ -156,7 +159,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}",
@@ -164,9 +174,7 @@
"doc_preview_iframe_title": "Vista previa del documento",
"doc_image_alt": "Escaneado original",
"doc_no_date": "Sin fecha",
"person_merge_will_be_deleted": "será eliminado.",
"comp_typeahead_placeholder": "Escriba un nombre...",
"comp_typeahead_loading": "Buscando...",
"comp_multiselect_placeholder": "Escriba un nombre...",
@@ -175,5 +183,62 @@
"comp_taginput_placeholder_create": "Añadir etiquetas...",
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
"comp_taginput_remove": "Eliminar etiqueta",
"comp_taginput_create_hint": "Pulse Enter para crear etiqueta."
"comp_taginput_create_hint": "Pulse Enter para crear etiqueta.",
"error_email_already_in_use": "Esta dirección de correo ya está en uso por otra cuenta.",
"error_wrong_current_password": "La contraseña actual es incorrecta.",
"nav_profile": "Perfil",
"profile_heading": "Mi perfil",
"profile_section_personal": "Información personal",
"profile_label_first_name": "Nombre",
"profile_label_last_name": "Apellido",
"profile_label_birth_date": "Fecha de nacimiento",
"profile_label_email": "Correo electrónico",
"profile_label_contact": "Datos de contacto",
"profile_contact_placeholder": "Teléfono, dirección u otras notas...",
"profile_section_password": "Cambiar contraseña",
"profile_label_current_password": "Contraseña actual",
"profile_label_new_password": "Nueva contraseña",
"profile_label_new_password_confirm": "Nueva contraseña (repetir)",
"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",
"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.",
"comp_expandable_show_more": "Mostrar más",
"comp_expandable_show_less": "Mostrar menos"
}

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

@@ -7,7 +7,7 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
@@ -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

@@ -8,9 +8,5 @@
"pathPattern": "./messages/{locale}.json"
},
"baseLocale": "de",
"locales": [
"de",
"en",
"es"
]
"locales": ["de", "en", "es"]
}

View File

@@ -6,10 +6,17 @@ declare global {
interface User {
id: string;
username: string;
firstName?: string;
lastName?: string;
birthDate?: string;
email?: string;
contact?: string;
groups: {
name: string;
permissions: string[];
}[];
enabled: boolean;
createdAt: string;
}
interface Locals {

View File

@@ -5,13 +5,18 @@ 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)) {
const locale = detectLocale(event.request.headers.get('accept-language') ?? '');
if (locale) {
event.cookies.set(cookieName, locale, { path: '/', sameSite: 'lax', maxAge: cookieMaxAge, httpOnly: false });
event.cookies.set(cookieName, locale, {
path: '/',
sameSite: 'lax',
maxAge: cookieMaxAge,
httpOnly: false
});
}
}
return resolve(event);
@@ -25,65 +30,74 @@ const handleAuth: Handle = async ({ event, resolve }) => {
return resolve(event);
};
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
const handleParaglide: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
});
});
});
const userGroup: Handle = async ({ event, resolve }) => {
const auth = event.cookies.get('auth_token');
const auth = event.cookies.get('auth_token');
if (auth) {
try {
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const response = await fetch(`${apiUrl}/api/users/me`, {
headers: { Authorization: auth }
if (auth) {
try {
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const response = await fetch(`${apiUrl}/api/users/me`, {
headers: { Authorization: auth }
});
if (response.ok) {
const user = await response.json();
event.locals.user = user;
}
} catch (error) {
console.error('Error fetching user in hook:', error);
}
}
});
if (response.ok) {
const user = await response.json();
event.locals.user = user;
}
} catch (error) {
console.error('Error fetching user in hook:', error);
}
}
return resolve(event);
return resolve(event);
};
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
const isNotLoginTest = !request.url.includes('/api/users/me');
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
if (isApi && isNotLoginTest) {
const token = event.cookies.get('auth_token');
if (isApi) {
// If the request already carries an explicit Authorization header (e.g. the
// login action sends Basic auth), pass it through unchanged.
if (request.headers.has('Authorization')) {
return fetch(request);
}
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
// 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);
}
// Clone the request first to preserve the body
const clonedRequest = request.clone();
const token = event.cookies.get('auth_token');
// Create new request with Authorization header and preserved body
const modifiedRequest = new Request(clonedRequest, {
headers: {
...Object.fromEntries(clonedRequest.headers),
'Authorization': token
}
});
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
return fetch(modifiedRequest);
}
// Clone the request first to preserve the body
const clonedRequest = request.clone();
return fetch(request);
// Create new request with Authorization header and preserved body
const modifiedRequest = new Request(clonedRequest, {
headers: {
...Object.fromEntries(clonedRequest.headers),
Authorization: token
}
});
return fetch(modifiedRequest);
}
return fetch(request);
};
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);

View File

@@ -18,8 +18,8 @@ import { env } from '$env/dynamic/private';
import type { paths } from '$lib/generated/api';
export function createApiClient(fetch: typeof globalThis.fetch) {
return createClient<paths>({
baseUrl: env.API_INTERNAL_URL || 'http://localhost:8080',
fetch
});
return createClient<paths>({
baseUrl: env.API_INTERNAL_URL || 'http://localhost:8080',
fetch
});
}

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,305 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
let { url }: { url: string } = $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);
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
}
}
}
$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);
});
}
});
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]">
<!-- 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>
</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>
</div>
</div>
{/if}
</div>
</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

@@ -1,123 +1,143 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
interface Props {
selectedPersons?: Person[];
}
interface Props {
selectedPersons?: Person[];
}
let { selectedPersons = $bindable([]) }: Props = $props();
let { selectedPersons = $bindable([]) }: Props = $props();
let searchTerm = $state('');
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
let searchTerm = $state('');
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) { results = []; return; }
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
const all: Person[] = await res.json();
results = all.filter(p => !selectedPersons.some(s => s.id === p.id));
}
} catch { results = []; }
finally { loading = false; }
}, 300);
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) {
results = [];
return;
}
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
const all: Person[] = await res.json();
results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id));
}
} catch {
results = [];
} finally {
loading = false;
}
}, 300);
}
function selectPerson(person: Person) {
selectedPersons = [...selectedPersons, person];
searchTerm = '';
showDropdown = false;
results = [];
}
function selectPerson(person: Person) {
selectedPersons = [...selectedPersons, person];
searchTerm = '';
showDropdown = false;
results = [];
}
function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter(p => p.id !== id);
}
function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter((p) => p.id !== id);
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return { destroy() { document.removeEventListener('click', handleClick, true); } };
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedPersons as person}
<input type="hidden" name="receiverIds" value={person.id} />
{#each selectedPersons as person (person.id)}
<input type="hidden" name="receiverIds" value={person.id} />
{/each}
<div class="relative" use:clickOutside>
<div class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded bg-white min-h-[42px] focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy">
{#each selectedPersons as person}
<span class="inline-flex items-center gap-1 bg-brand-sand/40 text-brand-navy text-sm font-medium px-2 py-1 rounded">
{person.firstName} {person.lastName}
<button
type="button"
onclick={() => removePerson(person.id)}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
aria-label={m.comp_multiselect_remove()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>
{/each}
<div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
>
{#each selectedPersons as person (person.id)}
<span
class="inline-flex items-center gap-1 rounded bg-brand-sand/40 px-2 py-1 text-sm font-medium text-brand-navy"
>
{person.firstName}
{person.lastName}
<button
type="button"
onclick={() => removePerson(person.id)}
class="ml-0.5 text-brand-navy/50 hover:text-red-500 focus:outline-none"
aria-label={m.comp_multiselect_remove()}
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
{/each}
<input
bind:this={inputEl}
type="text"
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
</div>
<input
bind:this={inputEl}
type="text"
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
/>
</div>
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="z-50 bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm"
>
{#if loading}
<div class="p-2 text-gray-500 text-sm">{m.comp_multiselect_loading()}</div>
{:else}
{#each results as person}
<div
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>
{person.lastName}, {person.firstName}
</div>
{/each}
{/if}
</div>
{/if}
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
>
{#if loading}
<div class="p-2 text-sm text-gray-500">{m.comp_multiselect_loading()}</div>
{:else}
{#each results as person (person.id)}
<div
class="cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-brand-sand/30"
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>
{person.lastName}, {person.firstName}
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonMultiSelect from './PersonMultiSelect.svelte';
@@ -29,6 +29,7 @@ function receiverInputs() {
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

View File

@@ -1,129 +1,154 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
interface Props {
name: string;
label: string;
value?: string;
initialName?: string;
onchange?: (value: string) => void;
}
interface Props {
name: string;
label: string;
value?: string;
initialName?: string;
restrictToCorrespondentsOf?: string;
onchange?: (value: string) => void;
}
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
let {
name,
label,
value = $bindable(''),
initialName = '',
restrictToCorrespondentsOf,
onchange
}: Props = $props();
let searchTerm = $state('');
let searchTerm = $state(initialName);
// Sync with external changes (e.g. reset button) — also sets the initial value
$effect(() => {
searchTerm = initialName;
});
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
function handleInput() {
if (value && searchTerm !== initialName) {
value = '';
onchange?.('');
}
function handleInput() {
if (value && searchTerm !== initialName) {
value = '';
onchange?.('');
}
showDropdown = true;
clearTimeout(debounceTimer);
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const term = untrack(() => searchTerm);
const correspondentsOf = untrack(() => restrictToCorrespondentsOf);
loading = true;
try {
let url: string;
if (correspondentsOf) {
if (term.length >= 1) {
url = `/api/persons/${correspondentsOf}/correspondents?q=${encodeURIComponent(term)}`;
} else {
url = `/api/persons/${correspondentsOf}/correspondents`;
}
} else {
if (term.length < 1) {
results = [];
loading = false;
return;
}
url = `/api/persons?q=${encodeURIComponent(term)}`;
}
const res = await fetch(url);
results = res.ok ? await res.json() : [];
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
}
}, 300);
}
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) {
results = [];
return;
}
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
results = res.ok ? await res.json() : [];
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
}
}, 300);
}
function handleFocus() {
showDropdown = true;
if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!;
loading = true;
(async () => {
try {
const res = await fetch(`/api/persons/${personId}/correspondents`);
results = res.ok ? await res.json() : [];
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
}
})();
}
}
function selectPerson(person: Person) {
value = person.id!;
searchTerm = `${person.firstName} ${person.lastName}`;
showDropdown = false;
onchange?.(person.id!);
}
function selectPerson(person: Person) {
value = person.id!;
searchTerm = `${person.firstName} ${person.lastName}`;
showDropdown = false;
onchange?.(person.id!);
}
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() { document.removeEventListener('click', handleClick, true); }
};
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
<div class="relative" use:clickOutside>
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
<input type="hidden" {name} bind:value={value} />
<input type="hidden" name={name} bind:value={value} />
<input
bind:this={inputEl}
type="text"
id="{name}-search"
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder={m.comp_typeahead_placeholder()}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="text"
id="{name}-search"
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={handleFocus}
placeholder={m.comp_typeahead_placeholder()}
class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="z-50 bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
{#if loading}
<div class="p-2 text-gray-500 text-sm">{m.comp_typeahead_loading()}</div>
{:else}
{#each results as person}
<div
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>
<div class="flex items-center">
<span class="font-medium block truncate">
{person.lastName}, {person.firstName}
</span>
</div>
</div>
{/each}
{/if}
</div>
{/if}
{#if showDropdown && (results.length > 0 || loading)}
<div
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
>
{#if loading}
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div>
{:else}
{#each results as person (person.id)}
<div
class="relative cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-blue-100"
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>
<div class="flex items-center">
<span class="block truncate font-medium">
{person.lastName}, {person.firstName}
</span>
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonTypeahead from './PersonTypeahead.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -30,6 +30,7 @@ function hiddenInput(name: string) {
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
@@ -117,9 +118,12 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
.not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
@@ -129,7 +133,8 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await tick();
expect(hiddenInput('senderId')?.value).toBe('1');
});
@@ -141,7 +146,8 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
});
@@ -151,7 +157,8 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
});
});
@@ -167,7 +174,8 @@ describe('PersonTypeahead clearing a selection', () => {
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
onchange.mockClear();
@@ -179,6 +187,69 @@ describe('PersonTypeahead clearing a selection', () => {
});
});
// ─── Correspondent mode ───────────────────────────────────────────────────────
describe('PersonTypeahead correspondent mode', () => {
it('fetches correspondents immediately on focus when restrictToCorrespondentsOf is set', async () => {
mockFetchWithPersons();
render(PersonTypeahead, {
name: 'receiverId',
label: 'Empfänger',
restrictToCorrespondentsOf: 'person-a-id'
});
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/persons/person-a-id/correspondents')
);
});
it('shows correspondent results immediately on focus without typing', async () => {
mockFetchWithPersons();
render(PersonTypeahead, {
name: 'receiverId',
label: 'Empfänger',
restrictToCorrespondentsOf: 'person-a-id'
});
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
});
it('uses correspondents endpoint with q param when typing', async () => {
mockFetchWithPersons();
render(PersonTypeahead, {
name: 'receiverId',
label: 'Empfänger',
restrictToCorrespondentsOf: 'person-a-id'
});
await page.getByPlaceholder('Namen tippen...').fill('Anna');
await waitForDebounce();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/persons/person-a-id/correspondents?q=Anna')
);
});
it('uses normal persons endpoint when restrictToCorrespondentsOf is not set', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
await page.getByPlaceholder('Namen tippen...').fill('Anna');
await waitForDebounce();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Anna'));
});
});
// ─── Click outside ────────────────────────────────────────────────────────────
describe('PersonTypeahead click outside', () => {

View File

@@ -1,150 +1,156 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
interface Props {
tags?: string[];
allowCreation?: boolean;
}
interface Props {
tags?: string[];
allowCreation?: boolean;
}
let { tags = $bindable([]), allowCreation = true }: Props = $props();
let { tags = $bindable([]), allowCreation = true }: Props = $props();
let inputVal = $state('');
let suggestions: string[] = $state([]);
let activeIndex = $state(-1);
let showSuggestions = $state(false);
let inputVal = $state('');
let suggestions: string[] = $state([]);
let activeIndex = $state(-1);
let showSuggestions = $state(false);
async function fetchSuggestions(query: string) {
if (query.length < 2) {
suggestions = [];
return;
}
try {
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
const names: string[] = data.map((t: { name: string }) => t.name);
suggestions = names.filter((t) => !tags.includes(t));
showSuggestions = true;
}
} catch (e) {
console.error('Tag fetch error', e);
}
}
async function fetchSuggestions(query: string) {
if (query.length < 2) {
suggestions = [];
return;
}
try {
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
const names: string[] = data.map((t: { name: string }) => t.name);
const currentTags = untrack(() => tags);
suggestions = names.filter((t) => !currentTags.includes(t));
showSuggestions = true;
}
} catch (e) {
console.error('Tag fetch error', e);
}
}
function addTag(tag: string) {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed)) {
tags = [...tags, trimmed];
}
inputVal = '';
suggestions = [];
showSuggestions = false;
activeIndex = -1;
}
function addTag(tag: string) {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed)) {
tags = [...tags, trimmed];
}
inputVal = '';
suggestions = [];
showSuggestions = false;
activeIndex = -1;
}
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
addTag(suggestions[activeIndex]);
} else if (allowCreation) {
addTag(inputVal);
}
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
removeTag(tags.length - 1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % suggestions.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
addTag(suggestions[activeIndex]);
} else if (allowCreation) {
addTag(inputVal);
}
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
removeTag(tags.length - 1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % suggestions.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
}
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showSuggestions = false;
}
};
document.addEventListener('click', handleClick, true);
return { destroy() { document.removeEventListener('click', handleClick, true); } };
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showSuggestions = false;
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
</script>
<div class="w-full" use:clickOutside>
<!-- Tag Container -->
<div
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy bg-white min-h-[42px]"
>
<!-- Render Selected Tags -->
{#each tags as tag, i}
<span
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
>
{tag}
<button
type="button"
onclick={() => removeTag(i)}
aria-label={m.comp_taginput_remove()}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
</span>
{/each}
<!-- Tag Container -->
<div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
>
<!-- Render Selected Tags -->
{#each tags as tag, i (i)}
<span
class="flex items-center gap-1 rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy"
>
{tag}
<button
type="button"
onclick={() => removeTag(i)}
aria-label={m.comp_taginput_remove()}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
</span>
{/each}
<!-- Input Field -->
<div class="relative flex-1 min-w-[120px]">
<input
type="text"
bind:value={inputVal}
oninput={() => fetchSuggestions(inputVal)}
onkeydown={handleKeydown}
onfocus={() => fetchSuggestions(inputVal)}
placeholder={tags.length === 0
<!-- Input Field -->
<div class="relative min-w-[120px] flex-1">
<input
type="text"
bind:value={inputVal}
oninput={() => fetchSuggestions(inputVal)}
onkeydown={handleKeydown}
onfocus={() => fetchSuggestions(inputVal)}
placeholder={tags.length === 0
? allowCreation
? m.comp_taginput_placeholder_create()
: m.comp_taginput_placeholder_filter()
: ''}
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
/>
<!-- Typeahead Dropdown -->
{#if showSuggestions && suggestions.length > 0}
<ul
class="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded shadow-lg z-50 max-h-48 overflow-y-auto"
>
{#each suggestions as suggestion, i}
<li
role="option"
aria-selected={i === activeIndex}
tabindex="0"
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex
? 'bg-brand-sand/20 text-brand-navy font-bold'
<!-- Typeahead Dropdown -->
{#if showSuggestions && suggestions.length > 0}
<ul
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-gray-200 bg-white shadow-lg"
>
{#each suggestions as suggestion, i (i)}
<li
role="option"
aria-selected={i === activeIndex}
tabindex="0"
class="cursor-pointer px-3 py-2 text-sm hover:bg-brand-sand/20 {i === activeIndex
? 'bg-brand-sand/20 font-bold text-brand-navy'
: 'text-gray-700'}"
onclick={() => addTag(suggestion)}
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
>
{suggestion}
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if allowCreation}
<p class="text-xs text-gray-400 mt-1">{m.comp_taginput_create_hint()}</p>
{/if}
onclick={() => addTag(suggestion)}
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
>
{suggestion}
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if allowCreation}
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import TagInput from './TagInput.svelte';
@@ -24,6 +24,7 @@ function mockFetchEmpty() {
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
@@ -32,17 +33,13 @@ afterEach(() => {
describe('TagInput rendering', () => {
it('shows creation placeholder when allowCreation=true and no tags', async () => {
render(TagInput, { tags: [], allowCreation: true });
await expect
.element(page.getByPlaceholder('Schlagworte hinzufügen...'))
.toBeInTheDocument();
await expect.element(page.getByPlaceholder('Schlagworte hinzufügen...')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
});
it('shows filter placeholder when allowCreation=false', async () => {
render(TagInput, { tags: [], allowCreation: false });
await expect
.element(page.getByPlaceholder('Nach Schlagworten filtern...'))
.toBeInTheDocument();
await expect.element(page.getByPlaceholder('Nach Schlagworten filtern...')).toBeInTheDocument();
});
it('renders existing tags as chips', async () => {
@@ -117,8 +114,8 @@ describe('TagInput removing tags', () => {
it('removes a chip when its × button is clicked', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
// The × buttons have aria-label="Schlagwort entfernen"
const removeButtons = page.getByRole('button', { name: 'Schlagwort entfernen' });
await removeButtons.first().click();
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
await tick();
await expect.element(page.getByText('Familie')).not.toBeInTheDocument();
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-after-remove.png' });
@@ -126,8 +123,7 @@ describe('TagInput removing tags', () => {
it('removes the last tag on Backspace when the input is empty', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
const input = page.getByRole('textbox');
await input.click();
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
await userEvent.keyboard('{Backspace}');
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
await expect.element(page.getByText('Familie')).toBeInTheDocument();
@@ -181,7 +177,8 @@ describe('TagInput autocomplete', () => {
const input = page.getByRole('textbox');
await input.fill('Fa');
await waitForDebounce();
await page.getByRole('option', { name: 'Familie' }).click();
document.querySelector<HTMLElement>('[role="option"]')!.click();
await tick();
await expect.element(page.getByText('Familie')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestion-selected.png' });

View File

@@ -5,20 +5,23 @@ import * as m from '$lib/paraglide/messages.js';
* Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
*/
export type ErrorCode =
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
| 'FILE_UPLOAD_FAILED'
| 'USER_NOT_FOUND'
| 'IMPORT_ALREADY_RUNNING'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
| 'INTERNAL_ERROR';
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
| 'FILE_UPLOAD_FAILED'
| 'USER_NOT_FOUND'
| 'EMAIL_ALREADY_IN_USE'
| 'WRONG_CURRENT_PASSWORD'
| 'IMPORT_ALREADY_RUNNING'
| 'INVALID_RESET_TOKEN'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
| 'INTERNAL_ERROR';
export interface BackendError {
code: ErrorCode;
message: string; // English developer message — not shown to users
code: ErrorCode;
message: string; // English developer message — not shown to users
}
/**
@@ -26,29 +29,45 @@ export interface BackendError {
* Returns null if the body is not valid JSON or does not contain a code field.
*/
export async function parseBackendError(res: Response): Promise<BackendError | null> {
try {
const body = await res.json();
if (body && typeof body.code === 'string') {
return body as BackendError;
}
} catch {
// Body was not JSON
}
return null;
try {
const body = await res.json();
if (body && typeof body.code === 'string') {
return body as BackendError;
}
} catch {
// Body was not JSON
}
return null;
}
/** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */
export function getErrorMessage(code: ErrorCode | string | undefined): string {
switch (code) {
case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found();
case 'DOCUMENT_NO_FILE': return m.error_document_no_file();
case 'FILE_NOT_FOUND': return m.error_file_not_found();
case 'FILE_UPLOAD_FAILED': return m.error_file_upload_failed();
case 'USER_NOT_FOUND': return m.error_user_not_found();
case 'IMPORT_ALREADY_RUNNING':return m.error_import_already_running();
case 'UNAUTHORIZED': return m.error_unauthorized();
case 'FORBIDDEN': return m.error_forbidden();
case 'VALIDATION_ERROR': return m.error_validation_error();
default: return m.error_internal_error();
}
switch (code) {
case 'DOCUMENT_NOT_FOUND':
return m.error_document_not_found();
case 'DOCUMENT_NO_FILE':
return m.error_document_no_file();
case 'FILE_NOT_FOUND':
return m.error_file_not_found();
case 'FILE_UPLOAD_FAILED':
return m.error_file_upload_failed();
case 'USER_NOT_FOUND':
return m.error_user_not_found();
case 'EMAIL_ALREADY_IN_USE':
return m.error_email_already_in_use();
case 'WRONG_CURRENT_PASSWORD':
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 'UNAUTHORIZED':
return m.error_unauthorized();
case 'FORBIDDEN':
return m.error_forbidden();
case 'VALIDATION_ERROR':
return m.error_validation_error();
default:
return m.error_internal_error();
}
}

View File

@@ -4,6 +4,38 @@
*/
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;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getCurrentUser"];
put: operations["updateProfile"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tags/{id}": {
parameters: {
query?: never;
@@ -68,6 +100,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/users/me/password": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["changePassword"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons": {
parameters: {
query?: never;
@@ -132,6 +180,38 @@ export interface paths {
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;
@@ -148,6 +228,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;
@@ -164,22 +260,6 @@ export interface paths {
patch: operations["updateGroup"];
trace?: never;
};
"/api/users/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getCurrentUser"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tags": {
parameters: {
query?: never;
@@ -196,6 +276,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/received-documents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getPersonReceivedDocuments"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/documents": {
parameters: {
query?: never;
@@ -212,6 +308,54 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons/{id}/correspondents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getCorrespondents"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
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;
@@ -276,37 +420,76 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/users/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteUser"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AdminUpdateUserRequest: {
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email?: string;
contact?: string;
newPassword?: string;
groupIds?: string[];
};
AppUser: {
/** Format: uuid */
id: string;
username: string;
password?: string;
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email?: string;
contact?: string;
enabled: boolean;
groups: components["schemas"]["UserGroup"][];
/** Format: date-time */
createdAt: string;
};
UserGroup: {
/** Format: uuid */
id: string;
name: string;
permissions: string[];
};
UpdateProfileDTO: {
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email?: string;
contact?: string;
};
Tag: {
/** Format: uuid */
id: string;
name: string;
};
PersonUpdateDTO: {
firstName?: string;
lastName?: string;
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
};
Person: {
/** Format: uuid */
id: string;
firstName: string;
lastName: string;
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
};
DocumentUpdateDTO: {
title?: string;
@@ -351,28 +534,27 @@ export interface components {
email?: string;
initialPassword?: string;
groupIds?: string[];
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
contact?: string;
};
AppUser: {
/** Format: uuid */
id: string;
username: string;
password?: string;
email?: string;
enabled: boolean;
groups: components["schemas"]["UserGroup"][];
/** Format: date-time */
createdAt: string;
};
UserGroup: {
/** Format: uuid */
id: string;
name: string;
permissions: string[];
ChangePasswordDTO: {
currentPassword?: string;
newPassword?: string;
};
GroupDTO: {
name?: string;
permissions?: string[];
};
ResetPasswordRequest: {
token?: string;
newPassword?: string;
};
ForgotPasswordRequest: {
email?: string;
};
ImportStatus: {
/** @enum {string} */
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
@@ -382,6 +564,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;
@@ -391,6 +598,118 @@ 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;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
updateProfile: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateProfileDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
updateTag: {
parameters: {
query?: never;
@@ -472,9 +791,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": {
[key: string]: string;
};
"application/json": components["schemas"]["PersonUpdateDTO"];
};
};
responses: {
@@ -581,6 +898,28 @@ export interface operations {
};
};
};
changePassword: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChangePasswordDTO"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getPersons: {
parameters: {
query?: {
@@ -723,6 +1062,50 @@ export interface operations {
};
};
};
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;
@@ -743,6 +1126,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;
@@ -789,26 +1192,6 @@ export interface operations {
};
};
};
getCurrentUser: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AppUser"];
};
};
};
};
searchTags: {
parameters: {
query?: {
@@ -831,6 +1214,28 @@ export interface operations {
};
};
};
getPersonReceivedDocuments: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"][];
};
};
};
};
getPersonDocuments: {
parameters: {
query?: never;
@@ -853,6 +1258,75 @@ export interface operations {
};
};
};
getCorrespondents: {
parameters: {
query?: {
q?: string;
};
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"][];
};
};
};
};
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;
@@ -948,24 +1422,4 @@ export interface operations {
};
};
};
deleteUser: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@@ -0,0 +1,11 @@
/**
* Format an ISO date string (YYYY-MM-DD) for display.
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
*/
export function formatDate(isoDate: string): string {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(isoDate + 'T12:00:00'));
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { sortDocumentsByDate } from './sort';
const doc = (id: string, documentDate: string | null) =>
({ id, documentDate } as { id: string; documentDate: string | null });
({ id, documentDate }) as { id: string; documentDate: string | null };
describe('sortDocumentsByDate', () => {
it('sorts DESC by default — newest first', () => {

View File

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

View File

@@ -1,121 +1,196 @@
<script lang="ts">
import './layout.css';
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { setLocale, getLocale } from '$lib/paraglide/runtime';
import './layout.css';
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { setLocale, getLocale } from '$lib/paraglide/runtime';
let { children } = $props();
let { children, data } = $props();
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
const isAdmin = $derived(page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')));
const isAdmin = $derived(
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
);
// Set after client-side hydration completes. Used by E2E tests to know the
// page is interactive (event handlers registered) before they interact with it.
let hydrated = $state(false);
onMount(() => { hydrated = true; });
// Set after client-side hydration completes. Used by E2E tests to know the
// page is interactive (event handlers registered) before they interact with it.
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
let userMenuOpen = $state(false);
const userInitials = $derived.by(() => {
const first = data?.user?.firstName?.[0];
const last = data?.user?.lastName?.[0];
if (first && last) return (first + last).toUpperCase();
return null;
});
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
userMenuOpen = false;
}
};
document.addEventListener('click', handleClick, true);
return () => {
document.removeEventListener('click', handleClick, true);
};
}
</script>
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
{#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>
{#if !page.url.pathname.startsWith('/login')}
<header class="bg-white border-b border-gray-100 sticky top-0 z-50">
<!-- De Gruyter Brill purple accent strip -->
<div class="h-1 bg-brand-purple"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<!-- Logo & Nav -->
<div class="flex">
<div class="mr-10 flex flex-shrink-0 items-center">
<a href="/" class="flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-xl font-bold tracking-widest text-brand-navy uppercase"
>Familienarchiv</span
>
</a>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<!-- Logo & Nav -->
<div class="flex">
<div class="flex-shrink-0 flex items-center mr-10">
<a href="/" class="flex items-center" aria-label="Familienarchiv">
<span class="font-sans font-bold text-xl tracking-widest text-brand-navy uppercase">Familienarchiv</span>
</a>
</div>
<nav class="hidden sm:flex sm:space-x-1 items-center">
<a
href="/"
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
<nav class="hidden items-center sm:flex sm:space-x-1">
<a
href="/"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'text-brand-navy bg-brand-purple/15 rounded'
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
>
{m.nav_documents()}
</a>
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
{m.nav_documents()}
</a>
<a
href="/persons"
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
<a
href="/persons"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/persons')
? 'text-brand-navy bg-brand-purple/15 rounded'
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
>
{m.nav_persons()}
</a>
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
{m.nav_persons()}
</a>
<a
href="/conversations"
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
<a
href="/conversations"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/conversations')
? 'text-brand-navy bg-brand-purple/15 rounded'
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
>
{m.nav_conversations()}
</a>
{#if isAdmin}
<a
href="/admin"
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
{m.nav_conversations()}
</a>
{#if isAdmin}
<a
href="/admin"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/admin')
? 'text-brand-navy bg-brand-purple/15 rounded'
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
>
{m.nav_admin()}
</a>
{/if}
</nav>
</div>
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
{m.nav_admin()}
</a>
{/if}
</nav>
</div>
<!-- Right Side -->
<div class="flex items-center gap-3">
<!-- Language selector -->
<div class="flex items-center gap-1 border-r border-gray-200 pr-3">
{#each locales as locale}
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="text-xs font-sans tracking-widest px-1.5 py-1 transition-colors
<!-- Right Side -->
<div class="flex items-center gap-3">
<!-- Language selector -->
<div class="flex items-center gap-1 border-r border-gray-200 pr-3">
{#each locales as locale (locale)}
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
{activeLocale === locale
? 'font-bold text-brand-navy'
: 'font-normal text-gray-400 hover:text-brand-navy'}"
>
{locale}
</button>
{/each}
</div>
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="inline-flex items-center gap-1.5 text-xs text-gray-400 hover:text-brand-navy font-bold uppercase font-sans tracking-widest px-3 py-2 transition-colors"
>
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg" alt="" aria-hidden="true" class="w-4 h-4 opacity-50" />
{m.nav_logout()}
</button>
</form>
</div>
</div>
>
{locale}
</button>
{/each}
</div>
</div>
</header>
{/if}
<!-- User menu -->
<div
class="relative"
{@attach clickOutside}
onkeydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }}
role="none"
>
{#if userInitials}
<button
type="button"
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
>
{userInitials}
</button>
{:else}
<button
type="button"
aria-label={m.nav_profile()}
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-50"
/>
</button>
{/if}
<main class="py-6">
{@render children()}
</main>
{#if userMenuOpen}
<div
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-brand-sand bg-white shadow-md"
>
<a
href="/profile"
onclick={() => (userMenuOpen = false)}
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
>
{m.nav_profile()}
</a>
<div class="border-t border-brand-sand">
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
>
{m.nav_logout()}
</button>
</form>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
</header>
{/if}
<main class="py-6">
{@render children()}
</main>
</div>

View File

@@ -2,59 +2,60 @@ import { redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
export async function load({ url, fetch }) {
const q = url.searchParams.get('q') || '';
const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || '';
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
const tags = url.searchParams.getAll('tag');
const q = url.searchParams.get('q') || '';
const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || '';
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
const tags = url.searchParams.getAll('tag');
const api = createApiClient(fetch);
const api = createApiClient(fetch);
try {
const [docsResult, personsResult] = await Promise.all([
api.GET('/api/documents/search', {
params: {
query: {
q: q || undefined,
from: from || undefined,
to: to || undefined,
senderId: senderId || undefined,
receiverId: receiverId || undefined,
tag: tags.length ? tags : undefined
}
}
}),
api.GET('/api/persons')
]);
try {
const [docsResult, personsResult] = await Promise.all([
api.GET('/api/documents/search', {
params: {
query: {
q: q || undefined,
from: from || undefined,
to: to || undefined,
senderId: senderId || undefined,
receiverId: receiverId || undefined,
tag: tags.length ? tags : undefined
}
}
}),
api.GET('/api/persons')
]);
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
throw redirect(302, '/login');
}
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
throw redirect(302, '/login');
}
const documents = docsResult.data ?? [];
const allPersons: { id: string; firstName: string; lastName: string }[] = personsResult.data ?? [];
const documents = docsResult.data ?? [];
const allPersons: { id: string; firstName: string; lastName: string }[] =
personsResult.data ?? [];
const senderObj = allPersons.find(p => p.id === senderId);
const receiverObj = allPersons.find(p => p.id === receiverId);
const senderObj = allPersons.find((p) => p.id === senderId);
const receiverObj = allPersons.find((p) => p.id === receiverId);
return {
documents,
initialValues: {
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
},
filters: { q, from, to, senderId, receiverId, tags },
error: null as string | null
};
} catch (e) {
if ((e as { status?: number }).status) throw e;
console.error('Error loading data:', e);
return {
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { q, from, to, senderId, receiverId, tags },
error: 'Daten konnten nicht geladen werden.' as string | null
};
}
return {
documents,
initialValues: {
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
},
filters: { q, from, to, senderId, receiverId, tags },
error: null as string | null
};
} catch (e) {
if ((e as { status?: number }).status) throw e;
console.error('Error loading data:', e);
return {
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { q, from, to, senderId, receiverId, tags },
error: 'Daten konnten nicht geladen werden.' as string | null
};
}
}

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