Compare commits

..

103 Commits

Author SHA1 Message Date
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
fb08eb30a4 feat(persons): add sort toggle to person document list (issue #24)
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 1m55s
CI / Backend Unit Tests (pull_request) Successful in 2m9s
CI / E2E Tests (pull_request) Successful in 18m8s
Extracted sortDocumentsByDate utility with full Vitest coverage (6 tests),
wired it into the person detail page with a DESC/ASC toggle button, and
added an E2E smoke test for the toggle interaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:24:46 +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
Marcel
fe9b4a9569 fix(e2e): fix locale cookie httpOnly and add hydration waits
Some checks failed
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 2m3s
CI / Backend Unit Tests (push) Successful in 2m13s
CI / E2E Tests (push) Successful in 17m49s
Paraglide's client-side setLocale writes the locale via document.cookie,
which silently fails for HttpOnly cookies. SvelteKit's cookies.set()
defaults to httpOnly: true, so locale switching never worked in tests.
Fix by setting httpOnly: false on the locale cookie (it's a UI preference,
not a credential — no security concern).

Add waitForSelector('[data-hydrated]') before any click that relies on
SvelteKit JavaScript event handlers. Without this, the click fires before
hydration and the onclick handler is not yet registered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:34:40 +01:00
Marcel
7988c62246 fix(e2e): fix strict mode violation and conversations sort toggle
- Add exact: true to all language button selectors in lang.spec.ts to
  prevent Playwright from matching "Abmelden" (contains "de") alongside
  the DE language button
- Fix goto in conversations applyFilters to use absolute path
  /conversations?... instead of relative ?... so URL updates correctly
- Set locale: 'de-DE' in playwright.config.ts so Accept-Language header
  is consistent and locale detection defaults to German during tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:34:40 +01:00
Marcel
a998ef4550 test(i18n): add unit tests for locale detection + extract to module
Extract detectLocale() from hooks.server.ts into src/lib/server/locale.ts
so it can be tested in isolation. Add 7 unit tests covering:
- German, English, Spanish browser preferences
- Fallback when primary language is unsupported
- Quality value (q=) ordering
- Fully unsupported language → null
- Empty Accept-Language header → null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:34:40 +01:00
Marcel
c4be2eb46e feat(i18n): detect browser language as default locale
On first visit (no PARAGLIDE_LOCALE cookie), parse the Accept-Language
request header and set the cookie to the best matching supported locale
(de/en/es). The user's manual choice via the switcher always takes
precedence since the detection is skipped when the cookie exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:34:40 +01:00
Marcel
20313de4e9 feat(i18n): fix remaining hardcoded strings and add login page switcher
- Add 9 missing translation keys to de/en/es.json:
  doc_file_error_preview, doc_download_title, doc_tag_filter_title,
  doc_conversation_title, doc_preview_iframe_title, doc_image_alt,
  doc_no_date, person_merge_will_be_deleted, admin_user_delete_confirm

- documents/[id]/+page.svelte: replace 6 hardcoded strings with m.*()
- persons/[id]/+page.svelte: replace "wird gelöscht." and "Kein Datum"
- admin/+page.svelte: replace confirm() string with m.admin_user_delete_confirm()
- login/+page.svelte: add top-right DE/EN/ES language switcher (Option B)
  and wire existing login_* keys to the form labels

Closes #12

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:34:40 +01:00
Marcel
4142c7cd83 devops: fix upload-artifact and use Playwright Docker image for unit tests
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 1m54s
CI / Backend Unit Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Failing after 17m35s
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
- Switch unit-tests job to mcr.microsoft.com/playwright:v1.58.2-noble
  container; Chromium and all system deps are pre-installed so the
  browser install/cache dance is eliminated entirely (closes #13)

- Downgrade upload-artifact@v4 → v3 in both unit-tests and e2e-tests
  jobs; v4 is not supported on Gitea (GHES) and was causing jobs to
  report failure even when all tests passed, and prevented the
  Playwright browser cache from ever being saved (closes #14)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 17:17:53 +01:00
Marcel
0e76be5672 feat: implement i18n — extract all UI strings, add EN + ES-MX translations, add language selector
Some checks failed
CI / Unit & Component Tests (push) Successful in 9m36s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / E2E Tests (push) Failing after 14m41s
Extract all hardcoded German strings from every .svelte file and component
into Paraglide message keys. Add complete translations for all keys in
messages/en.json (English) and messages/es.json (Spanish/Mexico).

Changes:
- messages/de.json: 100+ keys covering navigation, buttons, form labels,
  placeholders, section headings, empty states, and error messages
- messages/en.json, messages/es.json: complete translations for all keys
- project.inlang/settings.json: change baseLocale from "en" to "de"
- +layout.svelte: add DE/EN/ES language selector in header using setLocale();
  active language is bold, choice persists via Paraglide cookie strategy
- All 10 route pages + 3 shared components: replace hardcoded German with m.key()
- e2e/lang.spec.ts: E2E tests for language selector visibility, switching,
  persistence across navigation, and active state highlighting

Closes #2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 15:13:56 +01:00
Marcel
db6dc28528 fix(ci): pin DOCKER_API_VERSION=1.43 for e2e job
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 runner's Docker client negotiates API 1.53 but the daemon on the
NAS only supports up to 1.43. Pin the version for all docker commands
in the e2e job, including the new network connect step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 15:13:17 +01:00
Marcel
208dc87d69 fix(ci): connect job container to compose network for DB/MinIO access
The act_runner job runs inside a Docker container. Docker Compose port
mappings bind to the Docker host, not the job container's localhost —
so localhost:5433 was always refused.

Fix: after compose starts, connect the job container (identified by
/etc/hostname) to the archive-net compose network. Then switch the
backend startup args to use service names db:5432 and minio:9000
instead of host-mapped ports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 15:13:17 +01:00
Marcel
d943372ea7 fix(ci): pass docker-compose.ci.yml override to e2e compose commands
The e2e job was calling plain `docker compose up` without the CI
override file, so it used the base compose bind-mount for MinIO
(./data/minio) which doesn't exist on the runner. The CI override
replaces bind mounts with ephemeral named volumes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 15:13:17 +01:00
Marcel
4e8de3658f fix(api): use API_INTERNAL_URL in tags and persons proxy routes
Both SvelteKit API proxy routes were hardcoding http://localhost:8080,
breaking typeahead search in Docker environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 15:13:17 +01:00
Marcel
b6361e6cbc fix(auth): use API_INTERNAL_URL in userGroup hook
The userGroup hook was hardcoding http://localhost:8080 instead of
reading API_INTERNAL_URL from the environment. In Docker this caused
the /api/users/me fetch to fail silently, leaving event.locals.user
unset and triggering the handleAuth guard to redirect every page to
/login — including the login form action itself, creating an infinite
redirect loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 15:13:17 +01:00
Marcel
a3948b6a0f fix(test): add missing user, createdAt, updatedAt, and tag id fields to page spec fixtures
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 11m27s
CI / Backend Unit Tests (push) Successful in 2m11s
CI / E2E Tests (push) Failing after 56s
2026-03-19 12:51:35 +01:00
Marcel
0918e75803 docs: add Red/Green TDD workflow to COLLABORATING.md
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 8m48s
CI / Backend Unit Tests (pull_request) Successful in 1m56s
CI / E2E Tests (pull_request) Failing after 58s
CI / Unit & Component Tests (push) Successful in 8m22s
CI / Backend Unit Tests (push) Successful in 1m59s
CI / E2E Tests (push) Failing after 52s
2026-03-19 12:03:50 +01:00
Marcel
56cbd290e3 fix(e2e): fix Playwright E2E test suite for CI
- Replace __dirname with fileURLToPath(import.meta.url) for ESM compatibility
- Start SvelteKit dev server on port 3000 with 120s webServer timeout
- Add data-hydrated attribute (set in onMount) so tests wait for hydration
- Fix nav active class assertions: text-brand-navy (not border-brand-navy)
- Fix filter button selector: exact match to avoid matching "Alle Filter löschen"
- Fix date validation test: use pressSequentially('99') to trigger dateInvalid
- Fix person/document search: navigate directly to URL with query param
  (avoids debounced oninput → goto race condition in CI)
- Fix heading selector: level: 1 to avoid strict-mode with h1+h2 on page
- Fix auth redirect: return 401 from handleFetch instead of throwing redirect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:37 +01:00
Marcel
9f3f022ec0 ci: set up CI pipeline with unit, backend, and E2E test jobs
- Add unit-tests job using Playwright Docker image (no apt install needed)
- Add backend-unit-tests job with Java 21 + Maven
- Add e2e-tests job: PostgreSQL + MinIO via docker-compose, Spring Boot backend,
  SvelteKit dev server, Playwright Chromium
- Use non-conflicting host ports (DB: 15432, MinIO: 19000/19001)
- Install Docker CLI via official Docker apt repo (Playwright image has no daemon)
- Connect job container to archive-net for direct DB/MinIO access
- Pin DOCKER_API_VERSION=1.43 for Docker socket compatibility
- Start backend with java -jar + health-check loop (curl /actuator/health)
- Use continue-on-error on cleanup step to handle SIGKILL gracefully
- Downgrade upload-artifact to v3 (v4 not supported on self-hosted Gitea)
- Always run npm ci unconditionally (actions/cache@v4 broken on this runner)
- Log /tmp/backend.log on startup timeout so Spring Boot errors are visible
- Add diagnostic steps for DB tables and Flyway schema history

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:37 +01:00
Marcel
6ef7b292cc fix(flyway): baseline existing schemas at V4 on first run
Local dev databases that existed before Flyway was introduced have tables
but no flyway_schema_history. Flyway refuses to migrate a non-empty schema
without a history table. baselineOnMigrate=true with baselineVersion=4
stamps those databases as already at V4 without re-running migrations.
Fresh databases (CI) have an empty schema so the baseline is never
triggered and all 4 migrations run normally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:14 +01:00
Marcel
a65cbf9bae fix(backend): switch base image from alpine to debian for bash compatibility
mvnw is a bash script; eclipse-temurin:21-jdk-alpine only provides ash
(busybox), causing the container to exit silently with code 0 before the
JVM starts. The Debian-based eclipse-temurin:21-jdk image includes bash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:14 +01:00
Marcel
a60905674f fix(backend): explicit Flyway bean to bypass broken auto-configuration
Spring Boot 4.0 Flyway auto-configuration is not triggering in the CI
environment — confirmed by empty DB and no flyway_schema_history table.
Replace YAML-based auto-config with an explicit @Bean that creates and
runs Flyway directly on startup, independent of any auto-configuration
conditions. Disable the auto-config via spring.flyway.enabled=false to
prevent interference. Add @DependsOn("flyway") to DataInitializer to
enforce that CommandLineRunner beans are only registered after migrations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:14 +01:00
Marcel
e6db43850b fix(security): permit /actuator/health without authentication
The CI health check (curl -sf) and Docker Compose health check (wget)
both hit /actuator/health unauthenticated. With anyRequest().authenticated()
the endpoint returned 401, curl -f treated it as failure, and the health
check loop never exited successfully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:14 +01:00
Marcel
802f1ab0e0 fix(backend): explicit Flyway config and DataInitializer null title fix
Adding explicit spring.flyway.* config (url/user/password) ensures Flyway
creates its own JDBC connection and runs migrations independently of the JPA
datasource initialization order in Spring Boot 4.0.

Fix DataInitializer creating a Document with title=null, which would hit the
NOT NULL constraint in the documents table once the admin user init succeeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:14 +01:00
Marcel
9b67db74eb feat: auto-start Spring Boot backend via docker-compose
Replace the devcontainer (sleep infinity + VS Code image) with a proper
dev setup:
- Dockerfile: eclipse-temurin:21-jdk-alpine running ./mvnw spring-boot:run
- Source mounted at /app, Maven deps cached in named volume maven_cache
- Healthcheck on /actuator/health so frontend waits until backend is ready
- frontend depends_on backend: service_healthy (was service_started)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:14 +01:00
Marcel
3280125140 feat: add frontend dev container to docker-compose
- frontend/Dockerfile: Node 20 Alpine image running npm run dev
- docker-compose: frontend service with depends_on db/minio/backend,
  source mounted as volume, named volume for node_modules to avoid
  OS binary conflicts between host and container
- vite.config.ts: make API proxy target configurable via
  API_PROXY_TARGET env var (defaults to localhost:8080 for local dev,
  set to http://backend:8080 inside Docker)
- .env: update PORT_FRONTEND to 5173 (actual vite dev server port)

Usage:
  docker compose up frontend   # starts frontend + all dependencies
  docker compose up            # starts everything

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:14 +01:00
Marcel
553fa8a4b9 ci: cache Maven repository explicitly for both Java jobs
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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
The built-in cache: maven in setup-java@v4 does not reliably work
on self-hosted act runners. Replace with an explicit actions/cache@v4
on ~/.m2/repository keyed on pom.xml hash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:06:35 +01:00
Marcel
409e70078c ci: cache node_modules and Playwright browser binary
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
node_modules and the Playwright Chromium binary were downloaded fresh
on every run, making setup account for ~99% of pipeline runtime.

- Cache frontend/node_modules keyed on package-lock.json hash
- Cache ~/.cache/ms-playwright keyed on package-lock.json hash
- On cache hit: skip npm ci and browser download, only reinstall
  system deps (install-deps) which is much faster than a full install

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 22:03:40 +01:00
Marcel
5f36930b6b ci: use non-standard ports for DB and MinIO in e2e job
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 17m15s
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Avoids conflicts with any system services (PostgreSQL, MinIO) that
may already occupy 5432/9000/9001 on the runner host.
DB: 5433, MinIO API: 9100, MinIO console: 9101.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:44:09 +01:00
Marcel
b456c8f1bd ci: free host ports before starting e2e infrastructure
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
Port 5432 was already in use after docker compose cleanup because a
system-level PostgreSQL service on the runner host holds the port.
Also kill any stray containers binding to 5432/9000/9001.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:41:58 +01:00
Marcel
3f987ca48f ci: add backend unit test job to pipeline
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 / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
Runs ./mvnw clean test in a dedicated job — no DB or S3 needed
since all tests use Mockito or WebMvcTest slices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:37:50 +01:00
Marcel
225d6e44c9 test(backend): add service unit tests and controller slice tests
Some checks failed
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
Service unit tests (Mockito, no Spring context):
- DocumentServiceTest — getById, updateDocument, deleteTagCascading, createPlaceholder
- PersonServiceTest — getById, findOrCreateByAlias, mergePersons
- TagServiceTest — getById, findOrCreate, update, delete
- UserServiceTest — findByUsername, deleteUser, createUserOrUpdate, getGroupById

Controller slice tests (@WebMvcTest):
- DocumentControllerTest — 401/403/200 for GET /search, POST /, PUT /{id}
- TagControllerTest — 401/403/200 for GET, PUT /{id}, DELETE /{id}

Also removes FamilienarchivApplicationTests (full @SpringBootTest
requires DB + S3 infrastructure; covered by e2e job instead).

65 tests total, all passing.
Refs #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:33:24 +01:00
Marcel
ded5c24c40 doc: add branching and PR rules to collaboration guide
Some checks failed
CI / Unit & Component Tests (push) Successful in 17m20s
CI / E2E Tests (push) Failing after 36s
All changes must go through a feature branch and PR for review
before merging to main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:59:38 +01:00
Marcel
f4e6fe587c fix(ci): tear down leftover containers before e2e run
Some checks failed
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
Port 5432 was already bound by a zombie container from a previous
failed run, preventing docker compose from starting the DB.
Add a cleanup step at the top of the e2e job to ensure a clean state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:57:33 +01:00
Marcel
0d0aa83c0c fix(test): fix login page title test after DGB logo removal
Some checks failed
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
The logo was changed from an SVG to a plain <span>, causing
getByText('Familienarchiv') to match both the logo and the footer.
- Update test to use getByRole('link') for precision
- Remove "De Gruyter" from footer text to align with branding change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:55:34 +01:00
Marcel
dcb26e201a fix(ci): replace docker-compose v1 with docker compose v2
Some checks failed
CI / Unit & Component Tests (push) Failing after 12m35s
CI / E2E Tests (push) Failing after 1m1s
The act runner does not have the standalone docker-compose binary.
Replace both occurrences with the v2 plugin syntax (space instead of hyphen).
Closes #3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:25:06 +01:00
Marcel
fcaf6dc322 fix: show pointer 2026-03-17 18:52:52 +00:00
Marcel
f59f4d304a fix: reflect url filters on page 2026-03-17 18:47:10 +00:00
Marcel
f2030ca4ee refactor: remove de gruyter icon 2026-03-17 18:46:05 +00:00
Marcel
49fd29c1e6 refactor: use de gruyter ci 2026-03-17 18:35:13 +00:00
Marcel
3b04f4cafe doc: add styleguide 2026-03-17 18:06:50 +00:00
Marcel
ed32e1728d doc: add collab rules
Some checks failed
CI / Unit & Component Tests (push) Successful in 16m0s
CI / E2E Tests (push) Failing after 1m23s
2026-03-17 16:07:50 +00:00
Marcel
273b43261f chore: update ignore file 2026-03-17 13:35:32 +00:00
Marcel
7cb20dec50 test: add e2e tests 2026-03-17 13:34:05 +00:00
Marcel
973620a097 build: add mvn properties 2026-03-17 13:33:02 +00:00
Marcel
b3de5f885d ci: add workflows 2026-03-17 13:32:12 +00:00
Marcel
4417fc9828 refactor: migrate all Svelte components from Svelte 4 to Svelte 5 runes
- Replace `export let` with `$props()` and `$bindable()` across all components
- Replace `$:` reactive statements with `$derived()` and `$effect()`
- Replace `createEventDispatcher` with callback props (e.g. `onchange`)
- Replace `on:event` directives with inline event handlers (`onclick`, `oninput`, etc.)
- Replace `<slot />` with `{@render children()}` in layout
- Use `untrack()` for SSR-safe $state initialization from reactive props
- Replace `blur` + `setTimeout` anti-pattern in TagInput with `clickOutside` action
- Fix `page` store usage in layout to use `$app/state` directly
- 0 errors, 0 warnings after svelte-check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:43:26 +01:00
Marcel
25e095ea47 refactor: enforce Controller → Service → Repository layering throughout backend
- Created TagService: encapsulates all tag find/create/update/delete operations
- Extended PersonService: added findAll(), getById(), getAllById(), findOrCreateByAlias()
- Extended UserService: added createGroup(), updateGroup(), deleteGroup(), getGroupById()
- DocumentService: replaced direct PersonRepository/TagRepository access with
  PersonService/TagService calls; added getDocumentById(), getDocumentsBySender(),
  getConversationFiltered(), deleteTagCascading()
- MassImportService: replaced PersonRepository/TagRepository with PersonService/TagService
- PersonController: removed direct repo injections, delegates to PersonService/DocumentService
- DocumentController: removed DocumentRepository injection, delegates to DocumentService
- TagController: removed TagRepository/DocumentRepository, delegates to TagService/DocumentService
- GroupController: removed UserGroupRepository injection, delegates to UserService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 08:49:33 +01:00
Marcel
97e5255d7f refactor: move createPerson logic into PersonService
Controller was directly calling personRepository.save() for person creation.
Extracted into PersonService.createPerson() to enforce Controller → Service → Repository layering.
Also documented the layering rules in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 08:28:30 +01:00
Marcel
6b5c78f789 fix: align save bar width with form card on new person page
Replaced sticky full-bleed bar with a regular card-style row,
matching the form card width and adding mt-4 top margin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 16:25:44 +01:00
Marcel
0123dffdc4 feat: add create person feature via web interface
- Backend: new POST /api/persons endpoint in PersonController
- Frontend: new /persons/new route with Vorname/Nachname/Alias form,
  redirects to the new person's detail page on success
- Persons list: subtle '+ Neue Person' link below the page title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 16:23:05 +01:00
487 changed files with 14252 additions and 4032 deletions

199
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,199 @@
name: CI
on:
push:
pull_request:
jobs:
# ─── Unit & Browser Component Tests ──────────────────────────────────────────
# Runs inside the official Playwright Docker image — Chromium and all system
# deps are pre-installed, so no install or cache step is needed for the browser.
unit-tests:
name: Unit & Component Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
steps:
- uses: actions/checkout@v4
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: frontend/node_modules
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
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
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: unit-test-screenshots
path: frontend/test-results/screenshots/
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
# Pure Mockito + WebMvcTest — no DB or S3 needed.
backend-unit-tests:
name: Backend Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: temurin
- name: Cache Maven repository
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: maven-${{ hashFiles('backend/pom.xml') }}
restore-keys: maven-
- name: Run backend tests
run: |
chmod +x mvnw
./mvnw clean test
working-directory: backend
# ─── E2E Tests ────────────────────────────────────────────────────────────────
# Needs: PostgreSQL + MinIO (via docker-compose) + Spring Boot + SvelteKit dev server.
# Test data is seeded by DataInitializer on first startup (admin user + e2e profile data).
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
# These env vars are picked up by docker-compose (overrides .env file)
env:
DOCKER_API_VERSION: "1.43"
POSTGRES_USER: archive_user
POSTGRES_PASSWORD: ci_db_password
POSTGRES_DB: family_archive_db
MINIO_ROOT_USER: minio_admin
MINIO_ROOT_PASSWORD: ci_minio_password
MINIO_DEFAULT_BUCKETS: archive-documents
PORT_DB: 5433
PORT_MINIO_API: 9100
PORT_MINIO_CONSOLE: 9101
PORT_BACKEND: 8080
PORT_FRONTEND: 3000
steps:
- uses: actions/checkout@v4
# ── Infrastructure ──────────────────────────────────────────────────────
- name: Cleanup leftover containers from previous runs
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down --volumes --remove-orphans || true
- name: Start DB and MinIO
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d db minio create-buckets
- name: Wait for DB to be ready
run: |
timeout 30 bash -c \
'until docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T db pg_isready -U archive_user; do sleep 2; done'
- name: Connect job container to compose network
run: docker network connect familienarchiv_archive-net $(cat /etc/hostname)
# ── Backend ─────────────────────────────────────────────────────────────
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: temurin
- name: Cache Maven repository
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: maven-${{ hashFiles('backend/pom.xml') }}
restore-keys: maven-
- name: Build backend (skip tests — covered by separate Java test job)
run: |
chmod +x mvnw
./mvnw clean package -DskipTests
working-directory: backend
- name: Start backend
run: |
java -jar backend/target/*.jar \
--spring.profiles.active=e2e \
--SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/family_archive_db \
--SPRING_DATASOURCE_USERNAME=archive_user \
--SPRING_DATASOURCE_PASSWORD=ci_db_password \
--S3_ENDPOINT=http://minio:9000 \
--S3_ACCESS_KEY=minio_admin \
--S3_SECRET_KEY=ci_minio_password \
--S3_BUCKET_NAME=archive-documents \
--S3_REGION=us-east-1 \
--APP_ADMIN_USERNAME=admin \
--APP_ADMIN_PASSWORD=admin123 \
&
echo "Waiting for backend..."
timeout 90 bash -c \
'until curl -sf http://localhost:8080/actuator/health | grep -q "UP"; do sleep 3; done'
echo "Backend is up."
# ── Frontend ─────────────────────────────────────────────────────────────
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: frontend/node_modules
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
- name: Install frontend dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: npm ci
working-directory: frontend
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ hashFiles('frontend/package-lock.json') }}
- name: Install Playwright Chromium + system deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install chromium --with-deps
working-directory: frontend
- name: Install Playwright system deps (browser binary already cached)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
working-directory: frontend
# ── Tests ────────────────────────────────────────────────────────────────
- name: Run E2E tests
run: npm run test:e2e
working-directory: frontend
env:
E2E_BASE_URL: http://localhost:3000
E2E_USERNAME: admin
E2E_PASSWORD: admin123
- name: Upload E2E results
if: always()
uses: actions/upload-artifact@v3
with:
name: e2e-results
path: frontend/test-results/e2e/

5
.gitignore vendored
View File

@@ -1,9 +1,14 @@
# Runtime data (Docker volumes)
data/
import-data/
import/
gitea/
# Secrets
.env
# Dev scripts / DB dumps
scripts/large-data.sql
.vitest-attachments
**/test-results/

1
.husky/pre-commit Executable file
View File

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

321
CLAUDE.md
View File

@@ -4,23 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**Familienarchiv** is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel batch import, full-text search, conversation threads between family members, and role-based access control.
**Familienarchiv** is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel/ODS batch import, full-text search, conversation threads between family members, and role-based access control.
## Collaboration Principles
## Collaboration
**Be honest and objective**: Evaluate all suggestions, ideas, and feedback on their technical merits. Don't be overly complimentary or sycophantic. If something doesn't make sense, doesn't align with best practices, or could be improved, say so directly and constructively. Technical accuracy and project quality take precedence over being agreeable.
See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, and the Research → Plan → Implement → Validate cycle.
## Core Workflow: Research → Plan → Implement → Validate
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
**Start every feature with:** "Let me research the codebase and create a plan before implementing."
1. **Research** - Understand existing patterns and architecture
2. **Plan** - Propose approach and verify with you
3. **Implement** - Build with tests and error handling
4. **Validate** - ALWAYS run formatters, linters, and tests after implementation
- Whenever working on a feature or issue, let's always come up with a plan first, then save it to a file called `/.agent/current-plan.md`, before getting started with code changes. Update this file as the work progresses.
- Let's use pure functions where possible to improve readability and testing.
---
## Stack
@@ -62,48 +54,299 @@ npm run lint # Prettier + ESLint check
npm run format # Auto-fix formatting
npm run check # svelte-check (type checking)
npm run test # Vitest unit tests
npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
# (requires backend running with --spring.profiles.active=dev)
```
## Architecture
---
### Backend (`backend/src/main/java/org/raddatz/familienarchiv/`)
## Backend Architecture
Layered architecture:
### Package Structure
- **`model/`** — JPA entities: `Document`, `Person`, `AppUser`, `UserGroup`, `Tag`, `DocumentStatus` (enum: PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED)
- **`repository/`** — Spring Data repositories + `DocumentSpecifications` for complex filtered queries
- **`service/`** — Core business logic: `DocumentService` (uploads, search, Excel import), `FileService` (MinIO/S3), `ExcelService`, `MassImportService`, `UserService`
- **`controller/`** — REST endpoints: `DocumentController`, `PersonController`, `UserController`, `AdminController`, `GroupController`, `TagController`
- **`security/`** — `SecurityConfig` (HTTP Basic + form login, CSRF disabled), `PermissionAspect` (AOP enforcement of `@RequirePermission`), `CustomUserDetailsService`
- **`config/`** — `MinioConfig` (creates S3Client, validates connectivity on startup), `AsyncConfig`
```
backend/src/main/java/org/raddatz/familienarchiv/
├── controller/ REST endpoints — thin, delegate everything to services
├── service/ Business logic — the only place that touches repositories
├── repository/ Spring Data JPA interfaces
├── model/ JPA entities
├── dto/ Input objects (request bodies/form data)
├── exception/ DomainException + ErrorCode enum
├── security/ SecurityConfig, Permission enum, @RequirePermission, PermissionAspect
└── config/ MinioConfig, AsyncConfig
```
Database migrations live in `src/main/resources/db/migration/` (Flyway). Configuration in `src/main/resources/application.properties` — most values injected from environment variables (DB credentials, MinIO endpoint/credentials/bucket, upload limits, Excel column mappings).
### Layering Rules (strictly enforced)
### Frontend (`backend/workspaces/frontend/src/`)
```
Controller → Service → Repository → DB
```
- **`hooks.server.ts`** — Central middleware: reads `auth_token` cookie, injects it into all API calls to the backend, loads current user context
- **`routes/`** — File-based routing. Main pages: `/` (search/home), `/documents/[id]`, `/documents/[id]/edit`, `/persons`, `/persons/[id]`, `/conversations`, `/admin`, `/login`
- **`routes/api/`** — SvelteKit API endpoints for typeahead (persons, tags) — these call the Spring Boot backend
- **`lib/components/`** — `PersonTypeahead.svelte`, `TagInput.svelte`
- **`messages/`** — Paraglide.js translation files (`de.json`, `en.json`, `es.json`)
- **Controllers** never inject or call repositories directly.
- **Services** never reach into another domain's repository. Call the other domain's service instead.
- `DocumentService``PersonService.getById()``PersonRepository`
- `DocumentService``PersonRepository` directly
- This keeps domain boundaries clear and business logic testable in isolation.
Authentication: form login → backend sets session → `auth_token` cookie → hooks.server.ts injects into all backend requests.
### Domain Model
### Key Design Patterns
| Entity | Table | Key relationships |
|---|---|---|
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
- **Search**: `DocumentSpecifications` (Spring Data JPA Specification pattern) enables composable, dynamic query building for the document search endpoint
- **Permissions**: `@RequirePermission` annotation processed by `PermissionAspect` (AOP) — checks user's `UserGroup` permissions at the method level
- **Excel Import**: Configurable column index mapping in `application.properties`; `ExcelService` parses → `MassImportService` upserts documents
- **File Storage**: `FileService` wraps AWS SDK v2 `S3Client` with path-style access for MinIO compatibility
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
### Infrastructure
- `PLACEHOLDER`: created during Excel import, no file yet
- `UPLOADED`: file has been stored in S3
The `docker-compose.yml` at the repo root orchestrates everything. A MinIO MC helper container runs at startup to create the `archive-documents` bucket and set permissions. The backend container depends on both `db` and `minio` being healthy before starting.
### Entity Code Style
All entities use these Lombok annotations:
```java
@Entity
@Table(name = "table_name")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MyEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) // marks field as required in OpenAPI spec
private UUID id;
// ...
}
```
- `@Schema(requiredMode = REQUIRED)` must be added to every field the backend always populates (id, non-null fields). This drives the TypeScript type generation.
- Collections use `@Builder.Default` with `new HashSet<>()` as the default.
- Timestamps use `@CreationTimestamp` / `@UpdateTimestamp`.
### Services
Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optionally `@Slf4j`.
- Write methods are annotated `@Transactional`.
- Read methods are not annotated (default non-transactional is fine).
- Each service owns its domain's repository. Cross-domain data access goes through the other domain's service.
**Existing services:**
| Service | Responsibility |
|---|---|
| `DocumentService` | Document CRUD, search, tag cascade delete |
| `PersonService` | Person CRUD, find-or-create by alias |
| `TagService` | Tag find/create/update/delete |
| `UserService` | User and group CRUD |
| `FileService` | S3/MinIO upload and download |
| `MassImportService` | Async ODS/Excel import; delegates to PersonService and TagService |
| `ExcelService` | Lower-level spreadsheet parsing |
### DTOs
Input DTOs live in `dto/`. Response types are the model entities themselves (no response DTOs).
- `DocumentUpdateDTO` — used for both create and update (all fields optional)
- `CreateUserRequest` — user creation
- `GroupDTO` — group create/update
### Error Handling
Use `DomainException` for all domain errors. Never throw raw exceptions from service methods.
```java
// Static factories match common HTTP status codes:
DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)
DomainException.forbidden("Access denied")
DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "Already running")
DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Upload failed: " + e.getMessage())
```
`ErrorCode` is an enum in `exception/ErrorCode.java`. When adding a new error case, add the value there **and** mirror it in the frontend's `src/lib/errors.ts` + add a Paraglide translation key.
For simple validation in controllers (not domain logic), `ResponseStatusException` is acceptable:
```java
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "firstName is required");
```
### Security / Permissions
Use `@RequirePermission` on controller methods (or the whole controller class):
```java
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(...) { ... }
```
Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`
`PermissionAspect` (AOP) checks the current user's `UserGroup.permissions` at runtime.
### OpenAPI / API Types
SpringDoc generates the spec at `/v3/api-docs` (only accessible when running with `--spring.profiles.active=dev`).
When changing any model field or endpoint:
1. Rebuild the backend JAR with `-DskipTests`
2. Start it with `--spring.profiles.active=dev`
3. Run `npm run generate:api` in `frontend/`
---
## Frontend Architecture
### Route Structure
```
frontend/src/routes/
├── +layout.svelte Global header (sticky), nav links, logout
├── +layout.server.ts Loads current user, injects auth cookie
├── +page.svelte Home / document search
├── +page.server.ts Load: search documents; no actions
├── documents/
│ ├── [id]/+page.svelte Document detail (view + file preview)
│ └── [id]/edit/ Edit form (all metadata + file upload)
│ └── new/ Create form (same fields, empty)
├── persons/
│ ├── +page.svelte Person list with search
│ ├── [id]/+page.svelte Person detail (inline edit + merge)
│ └── new/ Create person form
├── conversations/ Bilateral conversation timeline
├── admin/ User + group + tag management
└── login/ logout/ Auth pages
```
### API Client Pattern
All server-side API calls use the typed client from `$lib/api.server.ts`:
```typescript
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
// Always check via response.ok, NOT result.error
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
```
Key rules:
- Use `!result.response.ok` for error checking (not `if (result.error)` — this breaks when the spec has no error responses defined)
- Cast errors as `result.error as unknown as { code?: string }` to extract the backend error code
- Use `result.data!` (non-null assertion) after an ok check — TypeScript knows it's present
For multipart/form-data endpoints (file uploads), bypass the typed client and use raw `fetch`:
```typescript
const res = await fetch(`${baseUrl}/api/documents`, { method: 'POST', body: formData });
```
### Form Actions Pattern
```typescript
// +page.server.ts
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = formData.get('name') as string; // cast needed — FormData returns FormDataEntryValue
// ...
return fail(400, { error: 'message' }); // on error
throw redirect(303, '/target'); // on success
}
};
```
### Date Handling
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO format to the backend.
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC timezone off-by-one:
```typescript
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
.format(new Date(doc.documentDate + 'T12:00:00'))
```
### UI Component Library
Custom components in `src/lib/components/`:
| Component | Props | Description |
|---|---|---|
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead dropdown |
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
### Styling Conventions (Tailwind CSS 4)
Brand color utilities (defined in `layout.css`):
| Class | Value | Usage |
|---|---|---|
| `brand-navy` | `#002850` | Primary text, buttons, headers |
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
| `brand-sand` | `#E4E2D7` | Page background, card borders |
Typography:
- `font-serif` (Merriweather) — body text, document titles, names
- `font-sans` (Montserrat) — labels, metadata, UI chrome
Card pattern for content sections:
```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section Title</h2>
<!-- content -->
</div>
```
Save bar pattern — use **sticky full-bleed** for long forms (edit document), **card-style with `mt-4`** for short forms (new person):
```svelte
<!-- Long forms: sticky, full-bleed -->
<div class="sticky bottom-0 z-10 -mx-4 px-6 py-4 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] flex items-center justify-between">
<!-- Short forms: card, top margin -->
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
```
Back link pattern:
```svelte
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4">
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" .../>
Zurück zur Übersicht
</a>
```
Subtle action link (e.g. "new document/person"):
```svelte
<a href="/documents/new" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors">
<svg class="w-4 h-4" ...><!-- plus icon --></svg>
Neues Dokument
</a>
```
### Error Handling (Frontend)
`src/lib/errors.ts` mirrors the backend `ErrorCode` enum and maps codes to Paraglide translation keys. When adding a new `ErrorCode` on the backend:
1. Add it to `ErrorCode.java`
2. Add it to the `ErrorCode` type in `errors.ts`
3. Add a `case` in `getErrorMessage()`
4. Add the translation key in `messages/de.json`, `en.json`, `es.json`
---
## Infrastructure
The `docker-compose.yml` at the repo root orchestrates everything. A MinIO MC helper container runs at startup to create the `archive-documents` bucket. The backend container depends on both `db` and `minio` being healthy.
Database migrations live in `backend/src/main/resources/db/migration/` (Flyway, SQL files named `V{n}__{description}.sql`).
## API Testing
HTTP test files are in `backend/api_tests/` (`Document.http`, `User.http`) for use with the VS Code REST Client extension.
HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client extension.
## Dev Container
A `.devcontainer/` config is available (Java 21 + Node 24, with ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" to get a pre-configured environment with Spring Boot Tools, Lombok support, and database/MinIO services running.
A `.devcontainer/` config is available (Java 21 + Node 24, ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" for a pre-configured environment.

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`.

151
COLLABORATING.md Normal file
View File

@@ -0,0 +1,151 @@
# Collaboration Rules
How we work together on this project.
## Honesty and Objectivity
Evaluate all suggestions on their technical merits. No sycophancy — if something doesn't make sense, doesn't align with best practices, or could be improved, say so directly and constructively. Technical accuracy and project quality take precedence over being agreeable.
## Core Workflow: Research → Plan → Implement → Validate
Every non-trivial feature or bug fix follows this sequence:
1. **Research** — Read the relevant code. Understand existing patterns before touching anything.
2. **Plan** — Write a plan to `/.agent/current-plan.md` and align with the user before writing code. Update the plan as work progresses.
3. **Implement** — Use Red/Green TDD (see below).
4. **Validate** — Run formatters, linters, and tests after every implementation step.
Never start writing code without having read the relevant files first.
## Red/Green TDD
All new behavior is driven by tests written **before** the implementation. The cycle is:
1. **Red** — Write a test that captures the requirement. Run it and confirm it fails. A test that passes before the implementation is written is not testing anything real.
2. **Green** — Write the minimum production code needed to make the test pass. No more.
3. **Refactor** — Clean up the implementation (names, structure, duplication) while keeping the test green.
4. **Commit** — The test and implementation ship together in a single logical commit.
Repeat for each new behavior.
### What level of test to write
| Scenario | Test type |
|---|---|
| Business logic, calculations, service rules | Unit test (`DocumentServiceTest`, etc.) |
| HTTP contract, request validation, error codes | Controller slice test (`@WebMvcTest`) |
| Full user-facing behavior, navigation, forms | E2E Playwright spec |
### Rules
- Never write production code without a failing test that requires it.
- Keep the Green step minimal — resist adding "obvious" extras that have no test yet.
- 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.
## 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.
Create an issue whenever work is identified that isn't being done in the current session.
### Issue title formats
**`feature` label** — user story format:
```
As a [role] I want [capability] so/because [reason]
```
Examples:
- "As a user I want to search documents so I can find a specific document faster"
- "As an admin I want to add a new user so I don't have to restart the server"
**`bug` label** — user-facing impact, not the technical cause:
```
[What breaks] when [trigger]
```
Examples:
- "Document list shows blank page when no results found"
- "Upload fails silently when file exceeds 50MB"
**`devops` label** — infrastructure, CI/CD, deployment, tooling:
- "Fix CI checkout failing due to unresolvable hostname"
- "Add E2E test seed data for runner"
### Priority labels
- `priority: high` — blocking or urgent
- `priority: medium` — normal
- `priority: low` — nice to have
### Other labels
- `needs-discussion` — decision needed before work starts
- `wontfix` — acknowledged, not addressing
## Branching and Pull Requests
All changes go through a branch and a pull request — never commit directly to `main`.
### Branch naming
```
<type>/<short-description>
```
Examples:
- `feat/received-documents-person-page`
- `fix/tag-filter-url-sync`
- `devops/docker-compose-v2`
### Workflow
1. Create a branch from `main` before writing any code.
2. Commit work to that branch.
3. Open a pull request on Gitea targeting `main` for review.
4. Wait for approval before merging.
The PR description should reference the related issue (`Closes #n` or `Refs #n`).
## Commit Messages
Every commit must reference the relevant Gitea issue.
- `Closes #12` — commit fully resolves the issue (Gitea auto-closes it)
- `Refs #12` — commit is related but doesn't fully close the issue
Place the reference at the end of the commit body:
```
feat: add person typeahead to document edit form
Closes #7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
```
### 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 — KISS beats DRY
- No backwards-compatibility shims for code that has no callers
- Validate at system boundaries only (user input, external APIs)

View File

@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip

View File

@@ -1,8 +1,9 @@
# Wir nutzen Java 21 (LTS), da Spring Boot 3 das empfiehlt
FROM mcr.microsoft.com/devcontainers/java:1-21-bullseye
FROM eclipse-temurin:21-jdk
# Optional: Zusätzliche OS-Pakete installieren
# RUN apt-get update && apt-get install -y <package-name>
WORKDIR /app
# Port für Spring Boot
EXPOSE 8080
# Source code and mvnw are mounted via docker-compose volume at runtime.
# Maven dependencies are cached in a named volume (~/.m2).
CMD ["./mvnw", "spring-boot:run"]

378
backend/mvnw.cmd vendored
View File

@@ -1,189 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

View File

@@ -4,13 +4,16 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.model.AppUser;
import org.springframework.context.annotation.DependsOn;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.raddatz.familienarchiv.repository.UserGroupRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
@@ -20,15 +23,12 @@ import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
@Configuration
@RequiredArgsConstructor
@Slf4j
@DependsOn("flyway")
public class DataInitializer {
@Value("${app.admin.username:admin}")
@@ -67,111 +67,106 @@ public class DataInitializer {
};
}
/**
* Deterministic seed data for E2E tests.
*
* Activated only with --spring.profiles.active=e2e (local and CI).
* Idempotent: skips seeding if persons already exist (e.g. on restart).
*
* Persons, tags, and documents are hardcoded so E2E assertions are stable
* across every run — no random names or dates.
*/
@Bean
@Profile("dev")
public CommandLineRunner initData(PersonRepository personRepo,
DocumentRepository docRepo) {
@Profile("e2e")
public CommandLineRunner initE2EData(PersonRepository personRepo,
DocumentRepository docRepo,
TagRepository tagRepo) {
return args -> {
// Nur ausführen, wenn DB leer ist
if (personRepo.count() > 0) {
log.info("Datenbank enthält bereits Daten. Überspringe Initialisierung.");
log.info("E2E seed: Daten bereits vorhanden, überspringe.");
return;
}
log.info("Generiere Testdaten...");
log.info("E2E seed: Erstelle deterministische Testdaten...");
// 1. Personen erstellen
List<Person> persons = new ArrayList<>();
String[] firstNames = { "Hans", "Helga", "Thomas", "Maria", "Otto", "Anna", "Paul", "Lisa" };
String[] lastNames = { "Müller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer" };
// ── Persons ──────────────────────────────────────────────────────
Person hans = personRepo.save(Person.builder()
.firstName("Hans").lastName("Müller").build());
Person anna = personRepo.save(Person.builder()
.firstName("Anna").lastName("Schmidt").build());
Person otto = personRepo.save(Person.builder()
.firstName("Otto").lastName("Fischer").build());
Person maria = personRepo.save(Person.builder()
.firstName("Maria").lastName("Weber").build());
for (int i = 0; i < 4; i++) {
String fn = firstNames[ThreadLocalRandom.current().nextInt(firstNames.length)];
String ln = lastNames[ThreadLocalRandom.current().nextInt(lastNames.length)];
// ── Tags ─────────────────────────────────────────────────────────
Tag tagFamilie = tagRepo.save(Tag.builder().name("Familie").build());
Tag tagKrieg = tagRepo.save(Tag.builder().name("Krieg").build());
Tag tagUrlaub = tagRepo.save(Tag.builder().name("Urlaub").build());
persons.add(personRepo.save(Person.builder()
.firstName(fn)
.lastName(ln)
.alias(i % 5 == 0 ? "Alias " + i : null)
.build()));
}
// Speichern (falls nicht im Loop geschehen, aber save returns entity)
// Hier nutzen wir die return values aus dem Loop, da save() die ID setzt.
// ── Documents ────────────────────────────────────────────────────
// 1. Fully transcribed letter — used by search + detail E2E tests
docRepo.save(Document.builder()
.title("Geburtsurkunde Hans Müller")
.originalFilename("geburtsurkunde_hans.pdf")
.status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1923, 4, 12))
.location("Berlin")
.sender(hans)
.receivers(Set.of(anna))
.tags(Set.of(tagFamilie))
.transcription("Hiermit wird beurkundet, dass Hans Müller am 12. April 1923 in Berlin geboren wurde.")
.build());
// 2. Dokumente erstellen
List<Document> documents = new ArrayList<>();
String[] cities = { "Berlin", "München", "Hamburg", "Köln" };
// 2. Letter with multiple receivers and tags — tests multi-receiver display
docRepo.save(Document.builder()
.title("Brief aus dem Krieg")
.originalFilename("brief_krieg_1944.pdf")
.status(DocumentStatus.TRANSCRIBED)
.documentDate(LocalDate.of(1944, 6, 6))
.location("Normandie")
.sender(otto)
.receivers(Set.of(anna, maria))
.tags(Set.of(tagKrieg, tagFamilie))
.transcription("Liebe Anna, ich schreibe dir aus der Front. Es geht mir den Umständen entsprechend gut.")
.build());
for (int i = 0; i < 500; i++) {
Person sender = persons.get(ThreadLocalRandom.current().nextInt(persons.size()));
// 3. Postcard — no transcription, tests PLACEHOLDER status
docRepo.save(Document.builder()
.title("Urlaubspostkarte Ostsee")
.originalFilename("postkarte_1965.jpg")
.status(DocumentStatus.PLACEHOLDER)
.documentDate(LocalDate.of(1965, 8, 3))
.location("Rügen")
.sender(anna)
.receivers(Set.of(hans))
.tags(Set.of(tagUrlaub))
.build());
// Zufällige Empfänger (0 bis 3)
Set<Person> receivers = new HashSet<>();
int numReceivers = ThreadLocalRandom.current().nextInt(4);
for (int j = 0; j < numReceivers; j++) {
receivers.add(persons.get(ThreadLocalRandom.current().nextInt(persons.size())));
}
// 4. Document with no sender — tests null-sender display ("Unbekannt")
docRepo.save(Document.builder()
.title("Unbekanntes Dokument")
.originalFilename("unbekannt.pdf")
.status(DocumentStatus.PLACEHOLDER)
.documentDate(LocalDate.of(1950, 1, 1))
.location("München")
.receivers(Set.of(maria))
.build());
Document doc = Document.builder()
.title("Dokument " + i)
.originalFilename("scan_" + i + ".pdf")
.status(i%2 == 0? DocumentStatus.TRANSCRIBED: DocumentStatus.PLACEHOLDER)
.documentDate(LocalDate.now().minusDays(ThreadLocalRandom.current().nextInt(365 * 50))) // Bis
// zu 50
// Jahre
// alt
.location(cities[ThreadLocalRandom.current().nextInt(cities.length)])
.transcription(i%2 == 0? LOREM_IPSUM_LANG: null)
.sender(sender)
.receivers(receivers)
.build();
// 5. Document with minimal metadata — tests sparse display
docRepo.save(Document.builder()
.title("Scan ohne Titel")
.originalFilename("scan_ohne_titel.pdf")
.status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1978, 11, 20))
.location("Hamburg")
.sender(maria)
.receivers(Set.of(otto))
.build());
documents.add(doc);
}
// Batch Save ist performanter
docRepo.saveAll(documents);
log.info("Initialisierung abgeschlossen: 4 Personen und 500 Dokumente erstellt.");
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente erstellt.",
personRepo.count(), tagRepo.count(), docRepo.count());
};
}
private final String LOREM_IPSUM_LANG="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. \n" + //
"\n" + //
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. \n" + //
"\n" + //
"Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. \n" + //
"\n" + //
"Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. \n" + //
"\n" + //
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. \n" + //
"\n" + //
"At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. \n" + //
"\n" + //
"Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus. \n" + //
"\n" + //
"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. \n" + //
"\n" + //
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. \n" + //
"\n" + //
"Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. \n" + //
"\n" + //
"Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. \n" + //
"\n" + //
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. \n" + //
"\n" + //
"At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. \n" + //
"\n" + //
"Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus. \n" + //
"\n" + //
"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. \n" + //
"\n" + //
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. \n" + //
"\n" + //
"Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. \n" + //
"\n" + //
"Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. \n" + //
"\n" + //
"Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. \n" + //
"\n" + //
"At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. ";
}

View File

@@ -0,0 +1,31 @@
package org.raddatz.familienarchiv.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.flywaydb.core.Flyway;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class FlywayConfig {
private final DataSource dataSource;
@Bean(name = "flyway")
public Flyway flyway() {
log.info("Running Flyway migrations...");
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.baselineVersion("4")
.load();
var result = flyway.migrate();
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
return flyway;
}
}

View File

@@ -46,6 +46,8 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> {
// Health endpoint must be open so CI/Docker health checks work without credentials
auth.requestMatchers("/actuator/health").permitAll();
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
if (environment.matchesProfiles("dev")) {
auth.requestMatchers(

View File

@@ -9,16 +9,15 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.FileService;
import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
@@ -39,26 +38,21 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class DocumentController {
private final DocumentRepository documentRepository;
private final DocumentService documentService;
private final FileService fileService;
// --- DOWNLOAD ---
@GetMapping("/{id}/file")
public ResponseEntity<InputStreamResource> getDocumentFile(@PathVariable UUID id) {
// 1. Look up path in DB
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
Document doc = documentService.getDocumentById(id);
if (doc.getFilePath() == null) {
throw DomainException.notFound(ErrorCode.DOCUMENT_NO_FILE, "Document has no file attached: " + id);
}
// 2. Delegate Retrieval to FileService
try {
FileService.S3FileDownload download = fileService.downloadFile(doc.getFilePath());
// Prefer the content type stored at upload time; fall back to whatever S3 reports
String contentType = (doc.getContentType() != null && !doc.getContentType().isBlank())
? doc.getContentType()
: download.contentType();
@@ -75,8 +69,7 @@ public class DocumentController {
// --- METADATA ---
@GetMapping("/{id}")
public Document getDocument(@PathVariable UUID id) {
return documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
return documentService.getDocumentById(id);
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -95,7 +88,7 @@ public class DocumentController {
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(
@PathVariable UUID id,
@ModelAttribute DocumentUpdateDTO dto, // Bindet Form-Felder automatisch
@ModelAttribute DocumentUpdateDTO dto,
@RequestPart(value = "file", required = false) MultipartFile file) {
try {
return documentService.updateDocument(id, dto, file);
@@ -121,18 +114,8 @@ public class DocumentController {
@RequestParam UUID receiverId,
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to,
@RequestParam(defaultValue = "DESC") String dir // ASC oder DESC
) {
// 1. Standard-Datumswerte setzen
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
LocalDate dateTo = (to != null) ? to : LocalDate.now();
// 2. Sortierung
Sort.Direction direction = Sort.Direction.fromString(dir.toUpperCase());
Sort sort = Sort.by(direction, "documentDate");
// 3. Abfrage
return documentRepository.findConversation(
senderId, receiverId, dateFrom, dateTo, sort);
@RequestParam(defaultValue = "DESC") String dir) {
Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate");
return documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
}
}

View File

@@ -5,12 +5,9 @@ import java.util.UUID;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.UserGroupRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.UserService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -28,33 +25,22 @@ import lombok.RequiredArgsConstructor;
@RequirePermission(Permission.ADMIN_PERMISSION)
@RequiredArgsConstructor
public class GroupController {
private final UserGroupRepository groupRepository;
private final UserService userService;
@PostMapping("")
public ResponseEntity<UserGroup> createGroup(@RequestBody GroupDTO dto) {
UserGroup group = new UserGroup();
group.setName(dto.getName());
group.setPermissions(dto.getPermissions()); // Assuming entity has Set<String> or Set<Enum>
return ResponseEntity.ok(groupRepository.save(group));
return ResponseEntity.ok(userService.createGroup(dto));
}
@PatchMapping("/{id}")
public ResponseEntity<UserGroup> updateGroup(@PathVariable UUID id, @RequestBody GroupDTO dto) {
UserGroup group = groupRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.INTERNAL_ERROR, "Group not found: " + id));
if (dto.getName() != null)
group.setName(dto.getName());
if (dto.getPermissions() != null)
group.setPermissions(dto.getPermissions());
return ResponseEntity.ok(groupRepository.save(group));
return ResponseEntity.ok(userService.updateGroup(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteGroup(@PathVariable UUID id) {
groupRepository.deleteById(id);
userService.deleteGroup(id);
return ResponseEntity.ok().build();
}
@@ -62,5 +48,4 @@ public class GroupController {
public ResponseEntity<List<UserGroup>> getAllGroups() {
return ResponseEntity.ok(userService.getAllGroups());
}
}

View File

@@ -1,57 +1,75 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
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.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/persons")
@RequiredArgsConstructor
public class PersonController {
private final PersonRepository personRepository;
private final DocumentRepository documentRepository;
private final PersonService personService;
private final DocumentService documentService;
@GetMapping
public ResponseEntity<List<Person>> getPersons(@RequestParam(required = false) String q) {
if (q != null && !q.isBlank()) {
return ResponseEntity.ok(personRepository.searchByName(q));
}
return ResponseEntity.ok(personRepository.findAllByOrderByLastNameAscFirstNameAsc());
return ResponseEntity.ok(personService.findAll(q));
}
@GetMapping("/{id}")
public Person getPerson(@PathVariable UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
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 documentRepository.findBySenderId(id);
return documentService.getDocumentsBySender(id);
}
@PutMapping("/{id}")
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
@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");
String lastName = body.get("lastName");
if (firstName == null || firstName.isBlank() || lastName == null || lastName.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")));
return ResponseEntity.ok(personService.createPerson(firstName.trim(), lastName.trim(), body.get("alias")));
}
@PutMapping("/{id}")
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");
}
dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
return ResponseEntity.ok(personService.updatePerson(id, dto));
}
@PostMapping("/{id}/merge")

View File

@@ -4,14 +4,12 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TagService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -21,46 +19,31 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/tags")
@RequiredArgsConstructor
public class TagController {
private final TagRepository tagRepository;
private final DocumentRepository documentRepository;
// Rename Tag
private final TagService tagService;
private final DocumentService documentService;
@PutMapping("/{id}")
@RequirePermission(Permission.ADMIN_TAG)
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody Map<String, String> payload) {
Tag tag = tagRepository.findById(id).orElseThrow();
tag.setName(payload.get("name"));
return ResponseEntity.ok(tagRepository.save(tag));
return ResponseEntity.ok(tagService.update(id, payload.get("name")));
}
// Delete Tag
@DeleteMapping("/{id}")
@RequirePermission(Permission.ADMIN_TAG)
@Transactional
public ResponseEntity<Void> deleteTag(@PathVariable UUID id) {
Tag tag = tagRepository.findById(id).orElseThrow();
// Remove tag from all documents first to prevent FK constraint errors
List<Document> documents = documentRepository.findByTags_Id(id);
for (Document doc : documents) {
doc.getTags().remove(tag);
documentRepository.save(doc);
}
tagRepository.delete(tag);
documentService.deleteTagCascading(id);
return ResponseEntity.ok().build();
}
@GetMapping
public List<Tag> searchTags(@RequestParam(defaultValue = "") String query) {
return tagRepository.findByNameContainingIgnoreCase(query);
return tagService.search(query);
}
}
}

View File

@@ -4,7 +4,9 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
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 +18,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 +37,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);
}

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

@@ -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,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 */

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

@@ -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,6 +30,8 @@ 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 DISTINCT d FROM Document d " +

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

@@ -9,8 +9,6 @@ import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -31,14 +29,14 @@ 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 PersonRepository personRepository;
private final PersonService personService;
private final FileService fileService;
private final TagRepository tagRepository;
private final TagService tagService;
/**
* Lädt eine Datei hoch.
@@ -104,17 +102,19 @@ 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) {
doc.setSender(personRepository.findById(dto.getSenderId()).orElse(null));
doc.setSender(personService.getById(dto.getSenderId()));
}
// Empfänger
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
doc.setReceivers(new HashSet<>(personRepository.findAllById(dto.getReceiverIds())));
doc.setReceivers(new HashSet<>(personService.getAllById(dto.getReceiverIds())));
}
// Datei
@@ -153,17 +153,14 @@ public class DocumentService {
// 2. Sender verknüpfen
if (dto.getSenderId() != null) {
Person sender = personRepository.findById(dto.getSenderId()).orElse(null);
doc.setSender(sender);
doc.setSender(personService.getById(dto.getSenderId()));
} else {
doc.setSender(null);
}
// 3. Empfänger verknüpfen
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
List<Person> receivers = personRepository.findAllById(dto.getReceiverIds());
doc.setReceivers(new HashSet<>(receivers));
doc.setReceivers(new HashSet<>(personService.getAllById(dto.getReceiverIds())));
} else {
doc.getReceivers().clear(); // Alle entfernen
}
@@ -185,7 +182,8 @@ public class DocumentService {
}
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<>();
@@ -195,11 +193,7 @@ public class DocumentService {
if (cleanName.isEmpty())
continue;
// Find existing or Create new
Tag tag = tagRepository.findByNameIgnoreCase(cleanName)
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
newTags.add(tag);
newTags.add(tagService.findOrCreate(cleanName));
}
doc.setTags(newTags);
@@ -226,12 +220,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
@@ -253,4 +246,32 @@ public class DocumentService {
return documentRepository.findAll(conversation, Sort.by(Sort.Direction.ASC, "documentDate"));
}
public Document getDocumentById(UUID id) {
return documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
}
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();
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
}
@Transactional
public void deleteTagCascading(UUID tagId) {
documentRepository.findByTags_Id(tagId).forEach(doc -> {
doc.getTags().removeIf(t -> t.getId().equals(tagId));
documentRepository.save(doc);
});
tagService.delete(tagId);
}
}

View File

@@ -10,8 +10,6 @@ import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@@ -57,8 +55,8 @@ public class MassImportService {
}
private final DocumentRepository documentRepository;
private final PersonRepository personRepository;
private final TagRepository tagRepository;
private final PersonService personService;
private final TagService tagService;
private final S3Client s3Client;
@Value("${app.s3.bucket}")
@@ -307,8 +305,7 @@ public class MassImportService {
Tag tag = null;
if (!tagRaw.isBlank()) {
tag = tagRepository.findByNameIgnoreCase(tagRaw)
.orElseGet(() -> tagRepository.save(Tag.builder().name(tagRaw).build()));
tag = tagService.findOrCreate(tagRaw);
}
Document doc = existing.orElse(Document.builder()
@@ -362,15 +359,7 @@ public class MassImportService {
}
private Person findOrCreatePerson(String rawName) {
String alias = rawName.trim();
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
PersonNameParser.SplitName split = PersonNameParser.split(alias);
return personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
});
return personService.findOrCreateByAlias(rawName);
}
private Optional<File> findFileRecursive(String filename) {

View File

@@ -1,6 +1,9 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
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;
@@ -8,7 +11,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
@@ -16,13 +19,65 @@ public class PersonService {
private final PersonRepository personRepository;
public List<Person> findAll(String q) {
if (q != null && !q.isBlank()) {
return personRepository.searchByName(q);
}
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
}
public Person getById(UUID id) {
return personRepository.findById(id)
.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);
}
@Transactional
public Person updatePerson(UUID id, String firstName, String lastName, String alias) {
public Person findOrCreateByAlias(String rawName) {
String alias = rawName.trim();
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
PersonNameParser.SplitName split = PersonNameParser.split(alias);
return personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
});
}
@Transactional
public Person createPerson(String firstName, String lastName, String alias) {
Person person = Person.builder()
.firstName(firstName)
.lastName(lastName)
.alias(alias == null || alias.isBlank() ? null : alias.trim())
.build();
return personRepository.save(person);
}
@Transactional
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

@@ -0,0 +1,47 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class TagService {
private final TagRepository tagRepository;
public List<Tag> search(String query) {
return tagRepository.findByNameContainingIgnoreCase(query);
}
public Tag getById(UUID id) {
return tagRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Tag nicht gefunden"));
}
public Tag findOrCreate(String name) {
String cleanName = name.trim();
return tagRepository.findByNameIgnoreCase(cleanName)
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
}
@Transactional
public Tag update(UUID id, String newName) {
Tag tag = getById(id);
tag.setName(newName);
return tagRepository.save(tag);
}
@Transactional
public void delete(UUID id) {
tagRepository.delete(getById(id));
}
}

View File

@@ -3,7 +3,10 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
import org.raddatz.familienarchiv.model.AppUser;
@@ -29,49 +32,84 @@ 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)
.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 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() {
@@ -81,4 +119,30 @@ public AppUser createUserOrUpdate(CreateUserRequest request) {
public List<UserGroup> getAllGroups() {
return groupRepository.findAll();
}
public UserGroup getGroupById(UUID id) {
return groupRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.INTERNAL_ERROR, "Group not found: " + id));
}
@Transactional
public UserGroup createGroup(GroupDTO dto) {
UserGroup group = new UserGroup();
group.setName(dto.getName());
group.setPermissions(dto.getPermissions());
return groupRepository.save(group);
}
@Transactional
public UserGroup updateGroup(UUID id, GroupDTO dto) {
UserGroup group = getGroupById(id);
if (dto.getName() != null) group.setName(dto.getName());
if (dto.getPermissions() != null) group.setPermissions(dto.getPermissions());
return groupRepository.save(group);
}
@Transactional
public void deleteGroup(UUID id) {
groupRepository.deleteById(id);
}
}

View File

@@ -8,6 +8,9 @@ spring:
password: ${SPRING_DATASOURCE_PASSWORD}
driver-class-name: org.postgresql.Driver
flyway:
enabled: false # Managed explicitly via FlywayConfig bean
jpa:
hibernate:
ddl-auto: none

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

@@ -1,13 +0,0 @@
package org.raddatz.familienarchiv;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class FamilienarchivApplicationTests {
@Test
void contextLoads() {
}
}

View File

@@ -0,0 +1,116 @@
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.FileService;
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.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.status;
@WebMvcTest(DocumentController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class DocumentControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean DocumentService documentService;
@MockitoBean FileService fileService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/documents/search ────────────────────────────────────────────
@Test
void search_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any()))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk());
}
// ─── POST /api/documents ─────────────────────────────────────────────────
@Test
void createDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(multipart("/api/documents"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void createDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(multipart("/api/documents"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createDocument_returns200_whenHasWritePermission() throws Exception {
Document doc = Document.builder()
.id(UUID.randomUUID())
.title("Test")
.originalFilename("test.pdf")
.build();
when(documentService.createDocument(any(), any())).thenReturn(doc);
mockMvc.perform(multipart("/api/documents"))
.andExpect(status().isOk());
}
// ─── PUT /api/documents/{id} ─────────────────────────────────────────────
@Test
void updateDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
.with(req -> { req.setMethod("PUT"); return req; }))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void updateDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
.with(req -> { req.setMethod("PUT"); return req; }))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateDocument_returns200_whenHasWritePermission() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder()
.id(id)
.title("Updated")
.originalFilename("test.pdf")
.build();
when(documentService.updateDocument(any(), any(), any())).thenReturn(doc);
mockMvc.perform(multipart("/api/documents/" + id)
.with(req -> { req.setMethod("PUT"); return req; }))
.andExpect(status().isOk());
}
}

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

@@ -0,0 +1,106 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TagService;
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.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TagController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class TagControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean TagService tagService;
@MockitoBean DocumentService documentService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/tags ────────────────────────────────────────────────────────
@Test
void searchTags_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/tags"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void searchTags_returns200_whenAuthenticated() throws Exception {
when(tagService.search(any())).thenReturn(List.of());
mockMvc.perform(get("/api/tags"))
.andExpect(status().isOk());
}
// ─── PUT /api/tags/{id} ───────────────────────────────────────────────────
@Test
void updateTag_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN_TAG")
void updateTag_returns200_whenHasAdminTagPermission() throws Exception {
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
when(tagService.update(any(), any())).thenReturn(tag);
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"New\"}"))
.andExpect(status().isOk());
}
// ─── DELETE /api/tags/{id} ────────────────────────────────────────────────
@Test
void deleteTag_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN_TAG")
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
.andExpect(status().isOk());
}
}

View File

@@ -0,0 +1,135 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import java.util.HashSet;
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 DocumentServiceTest {
@Mock DocumentRepository documentRepository;
@Mock PersonService personService;
@Mock FileService fileService;
@Mock TagService tagService;
@InjectMocks DocumentService documentService;
// ─── getDocumentById ──────────────────────────────────────────────────────
@Test
void getDocumentById_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> documentService.getDocumentById(id))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
}
@Test
void getDocumentById_returnsDocument_whenFound() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Test").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
}
// ─── updateDocument ───────────────────────────────────────────────────────
@Test
void updateDocument_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> documentService.updateDocument(id, new DocumentUpdateDTO(), null))
.isInstanceOf(DomainException.class);
}
// ─── deleteTagCascading ───────────────────────────────────────────────────
@Test
void deleteTagCascading_removesTagFromAllDocumentsAndDeletesTag() {
UUID tagId = UUID.randomUUID();
Tag tag = Tag.builder().id(tagId).name("Familie").build();
Document doc = Document.builder()
.id(UUID.randomUUID())
.tags(new HashSet<>(Set.of(tag)))
.build();
when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
documentService.deleteTagCascading(tagId);
assertThat(doc.getTags()).isEmpty();
verify(tagService).delete(tagId);
}
@Test
void deleteTagCascading_worksWhenNoDocumentsHaveTag() {
UUID tagId = UUID.randomUUID();
when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of());
documentService.deleteTagCascading(tagId);
verify(documentRepository, never()).save(any());
verify(tagService).delete(tagId);
}
// ─── createPlaceholder ────────────────────────────────────────────────────
@Test
void createPlaceholder_returnsExisting_whenAlreadyExists() {
String filename = "scan001.pdf";
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build();
when(documentRepository.existsByOriginalFilename(filename)).thenReturn(true);
when(documentRepository.findByOriginalFilename(filename)).thenReturn(Optional.of(existing));
Document result = documentService.createPlaceholder(filename);
assertThat(result).isEqualTo(existing);
verify(documentRepository, never()).save(any());
}
@Test
void createPlaceholder_createsNew_whenNotExists() {
String filename = "scan002.pdf";
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build();
when(documentRepository.existsByOriginalFilename(filename)).thenReturn(false);
when(documentRepository.save(any())).thenReturn(saved);
Document result = documentService.createPlaceholder(filename);
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);
}
}

View File

@@ -0,0 +1,251 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.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;
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 PersonServiceTest {
@Mock PersonRepository personRepository;
@InjectMocks PersonService personService;
// ─── getById ─────────────────────────────────────────────────────────────
@Test
void getById_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(personRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.getById(id))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
@Test
void getById_returnsPerson_whenFound() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
assertThat(personService.getById(id)).isEqualTo(person);
}
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
String alias = "Walter de Gruyter";
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(existing);
verify(personRepository, never()).save(any());
}
@Test
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
String alias = "Clara Cram";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenReturn(saved);
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(saved);
verify(personRepository).save(any());
}
@Test
void findOrCreateByAlias_trimsInput() {
String alias = " Clara Cram ";
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved));
personService.findOrCreateByAlias(alias);
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
void mergePersons_throwsBadRequest_whenSourceEqualsTarget() {
UUID id = UUID.randomUUID();
assertThatThrownBy(() -> personService.mergePersons(id, id))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void mergePersons_throwsNotFound_whenSourceMissing() {
UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
when(personRepository.findById(sourceId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
@Test
void mergePersons_throwsNotFound_whenTargetMissing() {
UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
Person source = Person.builder().id(sourceId).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(sourceId)).thenReturn(Optional.of(source));
when(personRepository.findById(targetId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
@Test
void mergePersons_reassignsDocumentsAndDeletesSource() {
UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
Person source = Person.builder().id(sourceId).firstName("Anna").lastName("Alt").build();
Person target = Person.builder().id(targetId).firstName("Anna").lastName("Neu").build();
when(personRepository.findById(sourceId)).thenReturn(Optional.of(source));
when(personRepository.findById(targetId)).thenReturn(Optional.of(target));
personService.mergePersons(sourceId, targetId);
verify(personRepository).reassignSender(sourceId, targetId);
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
verify(personRepository).deleteReceiverReferences(sourceId);
verify(personRepository).deleteById(sourceId);
}
}

View File

@@ -0,0 +1,131 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TagServiceTest {
@Mock TagRepository tagRepository;
@InjectMocks TagService tagService;
// ─── getById ─────────────────────────────────────────────────────────────
@Test
void getById_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(tagRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> tagService.getById(id))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
@Test
void getById_returnsTag_whenFound() {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("Familie").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
assertThat(tagService.getById(id)).isEqualTo(tag);
}
// ─── findOrCreate ─────────────────────────────────────────────────────────
@Test
void findOrCreate_returnsExisting_whenNameFound() {
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
when(tagRepository.findByNameIgnoreCase("Familie")).thenReturn(Optional.of(existing));
Tag result = tagService.findOrCreate("Familie");
assertThat(result).isEqualTo(existing);
verify(tagRepository, never()).save(any());
}
@Test
void findOrCreate_createsNew_whenNameNotFound() {
Tag saved = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
when(tagRepository.findByNameIgnoreCase("Krieg")).thenReturn(Optional.empty());
when(tagRepository.save(any())).thenReturn(saved);
Tag result = tagService.findOrCreate("Krieg");
assertThat(result).isEqualTo(saved);
verify(tagRepository).save(any());
}
@Test
void findOrCreate_trimsWhitespaceBeforeLookup() {
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Urlaub").build();
when(tagRepository.findByNameIgnoreCase("Urlaub")).thenReturn(Optional.of(existing));
tagService.findOrCreate(" Urlaub ");
verify(tagRepository).findByNameIgnoreCase("Urlaub");
}
// ─── update ───────────────────────────────────────────────────────────────
@Test
void update_savesNewName() {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("Old").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));
Tag result = tagService.update(id, "New");
assertThat(result.getName()).isEqualTo("New");
}
@Test
void update_throwsNotFound_whenTagMissing() {
UUID id = UUID.randomUUID();
when(tagRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> tagService.update(id, "New"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
// ─── delete ───────────────────────────────────────────────────────────────
@Test
void delete_callsRepositoryDelete() {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(id).name("ToDelete").build();
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
tagService.delete(id);
verify(tagRepository).delete(tag);
}
@Test
void delete_throwsNotFound_whenTagMissing() {
UUID id = UUID.randomUUID();
when(tagRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> tagService.delete(id))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
}

View File

@@ -0,0 +1,229 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.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;
import org.raddatz.familienarchiv.repository.UserGroupRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.argThat;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock AppUserRepository userRepository;
@Mock UserGroupRepository groupRepository;
@Mock PasswordEncoder passwordEncoder;
@InjectMocks UserService userService;
// ─── findByUsername ───────────────────────────────────────────────────────
@Test
void findByUsername_throwsNotFound_whenMissing() {
when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.findByUsername("ghost"))
.isInstanceOf(DomainException.class);
}
@Test
void findByUsername_returnsUser_whenFound() {
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("admin").build();
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
assertThat(userService.findByUsername("admin")).isEqualTo(user);
}
// ─── deleteUser ───────────────────────────────────────────────────────────
@Test
void deleteUser_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(userRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.deleteUser(id))
.isInstanceOf(DomainException.class);
}
@Test
void deleteUser_deletesUser_whenFound() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("gast").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
userService.deleteUser(id);
verify(userRepository).delete(user);
}
// ─── createUserOrUpdate ───────────────────────────────────────────────────
@Test
void createUserOrUpdate_createsNewUser_whenNotExists() {
CreateUserRequest req = new CreateUserRequest();
req.setUsername("newuser");
req.setEmail("new@example.com");
req.setInitialPassword("secret");
req.setGroupIds(List.of());
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
when(passwordEncoder.encode("secret")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
assertThat(result).isEqualTo(saved);
verify(userRepository).save(any());
}
@Test
void createUserOrUpdate_updatesExistingUser_whenFound() {
CreateUserRequest req = new CreateUserRequest();
req.setUsername("existing");
req.setEmail("existing@example.com");
req.setInitialPassword("newpass");
req.setGroupIds(List.of());
AppUser existing = AppUser.builder().id(UUID.randomUUID()).username("existing").build();
when(userRepository.findByUsername("existing")).thenReturn(Optional.of(existing));
when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(req);
// save called once with the updated existing user (no new user created)
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
void getGroupById_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(groupRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.getGroupById(id))
.isInstanceOf(DomainException.class);
}
}

18
docker-compose.ci.yml Normal file
View File

@@ -0,0 +1,18 @@
# CI override — replaces host bind mounts with ephemeral named volumes.
# Host port bindings are handled via PORT_DB/PORT_MINIO_API env vars in ci.yml
# (set to non-standard ports to avoid conflicts with system services on the runner).
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d db minio create-buckets
services:
db:
volumes:
- ci_postgres_data:/var/lib/postgresql/data
minio:
volumes:
- ci_minio_data:/data
volumes:
ci_postgres_data:
ci_minio_data:

View File

@@ -64,11 +64,11 @@ services:
context: ./backend
dockerfile: Dockerfile
container_name: archive-backend
command: sleep infinity
restart: unless-stopped
volumes:
- .:/workspaces/familienarchiv:cached
- ./import-data:/import # Mappt den lokalen Ordner "import-data" auf "/import" im Container
- ./backend:/app
- ./import:/import
- maven_cache:/root/.m2
depends_on:
db:
condition: service_healthy
@@ -78,35 +78,54 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# MinIO Konfiguration für Spring Boot (S3)
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
S3_REGION: us-east-1 # MinIO Standard
S3_REGION: us-east-1
ports:
- "${PORT_BACKEND}:8080"
networks:
- archive-net
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 60s
# --- Frontend: SvelteKit ---
# Auch hier brauchen wir erst das Dockerfile im frontend Ordner.
# frontend:
# build: ./frontend
# container_name: archive-frontend
# restart: unless-stopped
# depends_on:
# - backend
# environment:
# # SvelteKit SSR braucht die interne Docker-URL zum Backend
# API_BASE_URL: http://backend:8080
# # Der Browser braucht die öffentliche URL (falls Client-Side Fetching genutzt wird)
# PUBLIC_API_BASE_URL: http://localhost:${PORT_BACKEND}
# ports:
# - "${PORT_FRONTEND}:3000"
# networks:
# - archive-net
# --- Frontend: SvelteKit (Dev Server) ---
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: archive-frontend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
backend:
condition: service_healthy
volumes:
- ./frontend:/app
# Keep container's node_modules separate from host to avoid OS binary conflicts
- frontend_node_modules:/app/node_modules
environment:
# SSR calls (server-side) use the internal Docker network
API_INTERNAL_URL: http://backend:8080
# Vite dev proxy forwards /api from browser to the backend container
API_PROXY_TARGET: http://backend:8080
ports:
- "${PORT_FRONTEND}:5173"
networks:
- archive-net
networks:
archive-net:
driver: bridge
volumes:
frontend_node_modules:
maven_cache:

389
docs/STYLEGUIDE.md Normal file
View File

@@ -0,0 +1,389 @@
# Familienarchiv — Design Styleguide
This document defines the visual language for the Familienarchiv frontend. All UI work should follow these conventions to stay consistent with the De Gruyter Brill corporate identity.
---
## Brand Identity
The design is based on the **De Gruyter Brill** brand identity (unveiled at Frankfurt Book Fair 2024). Key characteristics:
- Clean white backgrounds, high contrast
- Strong typographic hierarchy (uppercase labels, serif body text)
- Academic publisher aesthetic: authoritative, clear, uncluttered
---
## Colors
Defined in `src/routes/layout.css` as `@theme` variables. All generate Tailwind utilities automatically (`bg-*`, `text-*`, `border-*`).
| Token | Hex | Usage |
|---|---|---|
| `brand-navy` | `#012851` | Primary text, headings, buttons, active states — Prussian Blue |
| `brand-mint` | `#A1DCD8` | Accent color, icon tints, hover underlines — Aqua Island |
| `brand-purple` | `#B4B9FF` | Logo, nav active state highlight, top accent strip — Melrose |
| `brand-sand` | `#F0EFE9` | Subtle card backgrounds, borders, hover backgrounds — paper tone |
| `brand-white` | `#FFFFFF` | Page background, card surfaces |
| `brand-dark` | `#0D0D0D` | Near-black text when maximum contrast is needed |
### Color usage rules
- **Never** use raw hex values in components — always use token utilities.
- Page and card backgrounds are **white**. Use `bg-brand-sand` only for subtle inset areas (e.g. `bg-brand-sand/30`).
- `brand-navy` is the workhorse: headings, body text, borders, primary buttons.
- `brand-mint` is an accent only — never use it as primary text color on white (contrast too low).
- `brand-purple` is reserved for the logo and the single top accent strip in the header.
---
## Typography
### Fonts
| Role | Font | Tailwind | Notes |
|---|---|---|---|
| Body / Serif | **Tinos** (Times substitute) | `font-serif` | Loaded from Google Fonts. Used for document titles, names, body copy, dates. Matches DGB's use of Times. |
| UI / Sans | **Montserrat** (Gotham substitute) | `font-sans` | Loaded from Google Fonts. Used for labels, navigation, buttons, metadata, form elements. Matches DGB's use of Gotham. |
### Type scale and usage
| Element | Classes | Example |
|---|---|---|
| Page title | `font-serif text-3xl text-brand-navy` | `<h1>` |
| Card section heading | `font-sans text-xs font-bold uppercase tracking-widest text-gray-400` | Section labels |
| Document / item title | `font-serif text-xl font-medium text-brand-navy` | List items |
| Metadata / label | `font-sans text-xs font-bold uppercase tracking-widest text-gray-500` | Field labels |
| Body text | `font-serif text-sm text-brand-navy` | Descriptions, summaries |
| Navigation | `font-sans text-xs font-bold uppercase tracking-widest` | Nav links |
### Rules
- **Labels are always uppercase + tracked**: `text-xs font-bold uppercase tracking-widest`
- **Headings** use `font-sans` (Montserrat), set in CSS globally.
- **Content** (document titles, person names, summaries) uses `font-serif` (Tinos).
- Never use `font-serif` for UI chrome (buttons, labels, nav).
---
## Icons
### Library
686 SVG icons in `frontend/static/degruyter-icons/`. Two families:
- **Simple** — single-color, action-oriented. Use for all UI icons. Available in 4 sizes.
- **Complex** — multi-color illustrative icons. Use sparingly for empty states or section headers.
### Simple icon sizes
| Size | Pixels | Path segment | Use |
|---|---|---|---|
| XS | 12px | `X-Small-12px` | Inline text hints, badges |
| SM | 16px | `Small-16px` | Compact UI, table cells |
| **MD** | **24px** | **`Medium-24px`** | **Standard UI icon — default choice** |
| LG | 32px | `Large-32px` | Feature headers, empty states |
### URL pattern
```
/degruyter-icons/Simple/{Size}/SVG/{Category}/{Name}-{Size-Code}.svg
```
**Size codes:** `XS`, `SM`, `MD`, `LG`
### Usage as `<img>` (recommended for static icons)
SVG fills are hardcoded to `#000000`. Use CSS to tint/size them:
```svelte
<!-- Standard icon -->
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt="" aria-hidden="true" class="w-6 h-6" />
<!-- Muted/secondary icon -->
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt="" aria-hidden="true" class="w-6 h-6 opacity-40" />
<!-- Colored via CSS filter (navy tint) -->
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt="" aria-hidden="true"
class="w-6 h-6"
style="filter: invert(11%) sepia(58%) saturate(1200%) hue-rotate(192deg) brightness(95%) contrast(101%)" />
```
> **Note:** Always include `alt=""` and `aria-hidden="true"` for decorative icons. For meaningful icons (no visible label next to them), use a descriptive `alt` text instead.
### Key icons for this app
| Use case | Icon path |
|---|---|
| Edit / Bearbeiten | `Action/Edit-Content-MD.svg` |
| Search / Suche | `Action/Mag-Glass-MD.svg` |
| New document | `Action/Add/Add-General-MD.svg` |
| Download | `Action/Download-MD.svg` |
| Upload | `Action/Upload-MD.svg` |
| Filter | `Action/Filter/Filter-Outline-MD.svg` |
| Calendar / date | `Action/Calendar/Calendar-Add-MD.svg` |
| Location | `Action/Location-MD.svg` |
| Person / account | `Action/Account-MD.svg` |
| Chat / conversation | `Action/Chat-MD.svg` |
| Tag / bookmark | `Action/Bookmark/Bookmark-Outline-MD.svg` |
| Close / dismiss | `Action/Close-MD.svg` |
| Back / left arrow | `Action/Arrow/Arrow-Left-MD.svg` |
| Settings / admin | `Action/Settings-MD.svg` |
| Document / PDF | `Action/PDF-Document-MD.svg` |
| Mail | `Action/Mail-MD.svg` |
| Delete | `Action/Remove/Remove-General-MD.svg` |
| Info | `Action/Info/Block/Info-Block-Border-MD.svg` |
---
## Spacing
Based on Tailwind's 4pt grid. Prefer multiples of 4 for all spacing.
| Scale | Value | Use |
|---|---|---|
| `p-1` / `gap-1` | 4px | Tight inline spacing |
| `p-2` | 8px | Small padding (badges, chips) |
| `p-3` | 12px | Compact buttons |
| `p-4` | 16px | Default section padding |
| `p-6` | 24px | Card inner padding (default) |
| `p-8` | 32px | Large card padding |
| `p-10` | 40px | Page vertical padding |
| `gap-6` | 24px | Grid/list gaps |
| `mb-6` | 24px | Standard spacing between sections |
| `mb-10` | 40px | Large spacing between card sections |
---
## Layout
### Page wrapper
All content pages use:
```svelte
<div class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
```
Narrower pages (forms, detail views):
```svelte
<div class="max-w-4xl mx-auto py-10 px-4">
```
### Header
The global sticky header in `+layout.svelte`:
- Height: **68px** (4px purple accent strip + 64px nav bar)
- Background: `bg-white`
- Bottom border: `border-b border-gray-100`
- Z-index: `z-50`
### Full-screen views
Document detail (`/documents/[id]`) uses a full-viewport split layout:
```svelte
<div class="h-screen flex flex-col bg-white">
<!-- top bar -->
<!-- content: sidebar + preview -->
</div>
```
---
## Components
### Card
Standard content card:
```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">
Section Title
</h2>
<!-- content -->
</div>
```
Card with colored accent bar (person/document detail):
```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm overflow-hidden">
<div class="h-2 bg-brand-navy w-full"></div>
<div class="p-8 md:p-10">
<!-- content -->
</div>
</div>
```
### Buttons
Primary button:
```svelte
<button class="bg-brand-navy text-white px-5 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors">
Speichern
</button>
```
Secondary / outline button:
```svelte
<button class="border border-gray-300 text-gray-600 px-5 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-gray-50 transition-colors rounded-sm">
Abbrechen
</button>
```
Ghost / text button (inline actions):
```svelte
<button class="text-brand-navy/60 hover:text-brand-navy text-sm font-medium font-sans transition-colors">
Aktion
</button>
```
Destructive button:
```svelte
<button class="border border-red-300 text-red-600 px-4 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-red-50 transition-colors rounded-sm">
Löschen
</button>
```
Button with DGB icon:
```svelte
<button class="inline-flex items-center gap-2 bg-brand-navy text-white px-4 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors">
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
alt="" aria-hidden="true" class="w-4 h-4 invert" />
Bearbeiten
</button>
```
> Use `class="invert"` on icons inside dark (navy) buttons to make the black SVG white.
### Form inputs
Label + input pair:
```svelte
<div>
<label for="field" class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1.5 font-sans">
Feldname *
</label>
<input
id="field" name="field" type="text"
class="block w-full border border-gray-300 py-2.5 px-3 text-sm font-serif text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
/>
</div>
```
Search input:
```svelte
<div class="relative">
<input type="text" placeholder="Suchen..."
class="block w-full border border-gray-300 py-2.5 pr-10 pl-3 font-sans text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" />
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt="" aria-hidden="true" class="w-4 h-4 opacity-40" />
</div>
</div>
```
### Status badges
```svelte
<!-- Mint (uploaded/active) -->
<span class="inline-flex items-center rounded-full border border-brand-mint/50 bg-brand-mint/20 text-brand-navy px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase font-sans">
UPLOADED
</span>
<!-- Yellow (placeholder/pending) -->
<span class="inline-flex items-center rounded-full border border-yellow-200 bg-yellow-50 text-yellow-700 px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase font-sans">
PLACEHOLDER
</span>
```
### Tag chips
```svelte
<button class="inline-flex items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase font-sans transition-colors hover:bg-brand-navy hover:text-white">
Schlagwort
</button>
```
### Back link
```svelte
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4 font-sans">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt="" aria-hidden="true"
class="w-4 h-4 mr-2 opacity-40 group-hover:opacity-100 transition-opacity" />
Zurück zur Übersicht
</a>
```
### Subtle "new item" link
```svelte
<a href="/documents/new" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors font-sans">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt="" aria-hidden="true" class="w-4 h-4 opacity-60" />
Neues Dokument
</a>
```
### Empty state
```svelte
<div class="p-16 text-center">
<div class="mx-auto mb-4 w-16 h-16 flex items-center justify-center">
<img src="/degruyter-icons/Simple/Large-32px/SVG/Action/Mag-Glass-LG.svg"
alt="" aria-hidden="true" class="w-10 h-10 opacity-20" />
</div>
<h3 class="font-serif text-lg font-medium text-brand-navy">Keine Dokumente gefunden</h3>
<p class="mt-1 font-sans text-sm text-gray-500">Versuchen Sie, die Filter anzupassen.</p>
</div>
```
### Nav active state
Current page nav link:
```svelte
class="text-brand-navy bg-brand-purple/15 rounded"
```
Inactive nav link:
```svelte
class="text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded"
```
Both share the base: `inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors`
### Save bar (long forms)
Sticky full-bleed (document edit):
```svelte
<div class="sticky bottom-0 z-10 -mx-4 px-6 py-4 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] flex items-center justify-between">
<a href="..." class="text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy font-sans transition-colors">
Abbrechen
</a>
<button type="submit" class="bg-brand-navy text-white px-6 py-2.5 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors">
Speichern
</button>
</div>
```
Card-style (short forms):
```svelte
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
```
---
## Do / Don't
| Do | Don't |
|---|---|
| Use `font-sans` for all UI labels, buttons, nav | Use `font-serif` for buttons or labels |
| Use `uppercase tracking-widest` for labels | Use sentence case for field labels |
| Use `brand-navy` for primary actions | Use `brand-mint` as primary action color |
| Use `opacity-*` to create icon tints | Change icon fill color inline |
| Use `invert` class on icons inside `bg-brand-navy` buttons | Use colored icon files for button icons |
| Use `rounded-sm` for cards and buttons (subtle) | Use `rounded-full` for non-pill elements |
| Use `shadow-sm` for card elevation | Use large shadows |
| Keep borders `border-gray-100` or `border-brand-sand` | Use dark borders |

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

15
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies as a separate layer so they are cached when only source changes
COPY package.json package-lock.json ./
RUN npm ci
# Source is mounted at runtime via docker-compose volume
# This COPY is only used when building without a volume (e.g. production image)
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

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": []
}

View File

@@ -0,0 +1,25 @@
import { test as setup } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
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: admin123)
*/
setup('authenticate', async ({ page }) => {
const username = process.env.E2E_USERNAME ?? 'admin';
const password = process.env.E2E_PASSWORD ?? 'admin123';
await page.goto('/login');
await page.getByLabel('Benutzername').fill(username);
await page.getByLabel('Passwort').fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await page.waitForURL('/');
await page.context().storageState({ path: authFile });
});

60
frontend/e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
/**
* These tests run WITHOUT the stored session so they can test the login flow itself.
* Playwright's storageState is only applied for the 'chromium' project, which depends
* on the 'setup' project. These tests use a fresh context via test.use({ storageState: undefined }).
*/
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Authentication', () => {
test('login page renders correctly', async ({ page }) => {
await page.goto('/login');
await expect(page.getByLabel('Benutzername')).toBeVisible();
await expect(page.getByLabel('Passwort')).toBeVisible();
await expect(page.getByRole('button', { name: 'Anmelden' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/login-page.png' });
});
test('redirects unauthenticated users to /login', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
await page.screenshot({ path: 'test-results/e2e/auth-redirect.png' });
});
test('protected routes redirect to /login without session', async ({ page }) => {
for (const url of ['/documents/new', '/persons', '/conversations']) {
await page.goto(url);
await expect(page).toHaveURL(/\/login/);
}
});
test('shows an error for wrong credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Benutzername').fill('nichtexistent');
await page.getByLabel('Passwort').fill('falschespasswort');
await page.getByRole('button', { name: 'Anmelden' }).click();
// Stays on login, shows error
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('.text-red-600')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/login-error.png' });
});
test('login with valid credentials redirects to home', async ({ page }) => {
await login(page);
await expect(page).toHaveURL('/');
await expect(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/login-success.png' });
});
test('logout clears the session and redirects to /login', async ({ page }) => {
await login(page);
await page.getByRole('button', { name: 'Abmelden' }).click();
await expect(page).toHaveURL(/\/login/);
// Confirm session is gone: navigating to / redirects back
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
await page.screenshot({ path: 'test-results/e2e/logout.png' });
});
});

View File

@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
/**
* Document management E2E tests.
* Assumes auth setup has run (storageState is applied by playwright.config.ts).
* Assumes the backend has at least one document in the database.
*/
test.describe('Document list', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Wait for SvelteKit hydration to complete so onclick/oninput handlers are active.
await page.waitForSelector('[data-hydrated]');
});
test('renders the search bar and document list', async ({ page }) => {
await expect(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toBeVisible();
await expect(page.getByRole('link', { name: /Neues Dokument/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-home.png' });
});
test('navigation bar shows active state for Dokumente', async ({ page }) => {
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' });
await expect(navLink).toHaveClass(/text-brand-navy/);
});
test('text search filters the document list', async ({ page }) => {
// Navigate directly with the query param — tests that search results are filtered
// correctly without depending on the debounced oninput → goto chain in CI.
await page.goto('/?q=zzz_unlikely_to_match_anything');
await expect(page.getByText('Keine Dokumente gefunden')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-search-no-results.png' });
});
test('clearing the search returns all documents', async ({ page }) => {
// Navigate with an active query first, then click the reset link.
await page.goto('/?q=xyz_unlikely');
await page.getByTitle('Filter zurücksetzen').click();
await page.waitForURL('/');
await expect(page).toHaveURL('/');
await page.screenshot({ path: 'test-results/e2e/documents-reset-search.png' });
});
test('advanced filters panel opens and closes', async ({ page }) => {
const btn = page.getByRole('button', { name: 'Filter', exact: true });
await btn.click();
await expect(page.getByLabel('Von')).toBeVisible();
await expect(page.getByLabel('Bis')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-filters-open.png' });
await btn.click();
await expect(page.getByLabel('Von')).not.toBeVisible();
});
test('date range filter triggers a new search', async ({ page }) => {
await page.getByRole('button', { name: 'Filter', exact: true }).click();
await page.getByLabel('Von').fill('2000-01-01');
await page.waitForURL(/from=2000-01-01/);
await expect(page).toHaveURL(/from=2000-01-01/);
await page.screenshot({ path: 'test-results/e2e/documents-date-filter.png' });
});
});
test.describe('Document detail', () => {
test('clicking a document opens the detail page', async ({ page }) => {
await page.goto('/');
// Click the first document link in the list
const firstDoc = page.locator('ul li a').first();
const href = await firstDoc.getAttribute('href');
await firstDoc.click();
await expect(page).toHaveURL(href!);
await page.screenshot({ path: 'test-results/e2e/document-detail.png' });
});
});
test.describe('New document', () => {
test('renders the upload form', async ({ page }) => {
await page.goto('/documents/new');
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible();
await expect(page.getByLabel('Titel')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-new.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
await page.goto('/');
const firstDocLink = page.locator('ul li a').first();
const href = await firstDocLink.getAttribute('href');
await page.goto(`${href}/edit`);
await expect(page.getByRole('heading', { name: /Bearbeiten/i })).toBeVisible();
await expect(page.getByLabel('Titel')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit.png' });
});
test('shows a validation error for an invalid date format', async ({ page }) => {
await page.goto('/');
const firstDocLink = page.locator('ul li a').first();
const href = await firstDocLink.getAttribute('href');
await page.goto(`${href}/edit`);
// Wait for hydration so oninput={handleDateInput} is registered.
await page.waitForSelector('[data-hydrated]');
const dateInput = page.getByLabel('Datum');
// Type partial digits: '99' → dateDisplay='99', dateIso='' → dateInvalid=true
await dateInput.fill('');
await dateInput.pressSequentially('99');
await expect(page.getByText(/TT\.MM\.JJJJ/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
});
});

View File

@@ -0,0 +1,13 @@
import type { Page } from '@playwright/test';
export async function login(
page: Page,
username = process.env.E2E_USERNAME ?? 'admin',
password = process.env.E2E_PASSWORD ?? 'admin123'
) {
await page.goto('/login');
await page.getByLabel('Benutzername').fill(username);
await page.getByLabel('Passwort').fill(password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await page.waitForURL('/');
}

60
frontend/e2e/lang.spec.ts Normal file
View File

@@ -0,0 +1,60 @@
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();
});
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: 'Persons' })).toBeVisible();
});
test('language choice persists after navigation', async ({ page }) => {
await page.goto('/');
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();
});
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();
// 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 }) => {
await page.goto('/');
const deBtn = page.getByRole('banner').getByRole('button', { name: 'DE', exact: true });
await expect(deBtn).toHaveClass(/font-bold/);
});
});

View File

@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';
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();
});
});

View File

@@ -0,0 +1,294 @@
import { test, expect } from '@playwright/test';
test.describe('Person list', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/persons');
});
test('renders the persons list page', async ({ page }) => {
await expect(page.getByRole('heading', { name: /Personen/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/persons-list.png' });
});
test('search filters the persons list', async ({ page }) => {
// Navigate directly with the query param — tests that search results are filtered
// correctly without depending on the debounced oninput → goto chain in CI.
await page.goto('/persons?q=zzz_unlikely_match');
await expect(page.getByText(/Keine Personen gefunden/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/persons-search-empty.png' });
});
test('clicking a person opens the detail page', async ({ page }) => {
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
await expect(page).toHaveURL(/\/persons\/.+/);
await page.screenshot({ path: 'test-results/e2e/person-detail.png' });
});
});
test.describe('Person detail', () => {
test('shows the person name and their documents', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
// The detail page shows the person's name as the top-level heading
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-detail-documents.png' });
});
test('can enter and cancel edit mode', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
// Click the edit button
const editBtn = page.getByRole('button', { name: /Bearbeiten/i });
if (await editBtn.isVisible()) {
await editBtn.click();
await expect(page.getByLabel('Vorname')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-edit-form.png' });
// Cancel
await page.getByRole('button', { name: /Abbrechen/i }).click();
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', () => {
test('renders the new person form', async ({ page }) => {
await page.goto('/persons/new');
await expect(page.getByLabel('Vorname')).toBeVisible();
await expect(page.getByLabel('Nachname')).toBeVisible();
await expect(page.getByRole('button', { name: /Erstellen/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-new.png' });
});
test('shows a validation error when submitting with empty fields', async ({ page }) => {
await page.goto('/persons/new');
// HTML required attribute prevents submission without filling required fields
await page.getByRole('button', { name: /Erstellen/i }).click();
// The form should not have navigated away
await expect(page).toHaveURL('/persons/new');
});
});
test.describe('Person detail — sort toggle', () => {
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]');
// 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=.+/);
}
});
});
test.describe('Conversations', () => {
test('shows the empty state when no persons are selected', async ({ page }) => {
await page.goto('/conversations');
await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' });
});
test('nav link is active on the conversations page', async ({ page }) => {
await page.goto('/conversations');
const navLink = page.getByRole('link', { name: 'Konversationen' });
await expect(navLink).toHaveClass(/text-brand-navy/);
});
test('sort toggle changes the button label', async ({ page }) => {
await page.goto('/conversations');
await page.waitForSelector('[data-hydrated]');
const btn = page.getByRole('button', { name: /Sortierung/i });
await expect(btn).toContainText('Neueste zuerst');
await btn.click();
await expect(page).toHaveURL(/dir=ASC/);
await expect(btn).toContainText('Älteste zuerst');
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();
await page.waitForURL(/senderId=/);
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

@@ -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",
"hello_world": "Hello, {name} from de!",
"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.",
@@ -10,5 +9,190 @@
"error_unauthorized": "Sie sind nicht angemeldet.",
"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."
"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",
"btn_create": "Erstellen",
"btn_delete": "Löschen",
"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",
"form_placeholder_alias": "z.B. Oma Frieda, Onkel Karl…",
"form_label_date": "Datum",
"form_placeholder_date": "TT.MM.JJJJ",
"form_date_error": "Bitte im Format TT.MM.JJJJ eingeben, z.B. 20.12.2026",
"form_label_location": "Ort",
"form_placeholder_location": "z.B. Berlin, Wien…",
"form_label_sender": "Absender",
"form_label_receivers": "Empfänger",
"form_label_title": "Titel *",
"form_label_tags": "Schlagworte",
"form_label_content": "Inhalt",
"form_placeholder_content": "Kurze Beschreibung des Inhalts…",
"form_label_transcription": "Transkription",
"form_placeholder_transcription": "Vollständiger Text des Dokuments…",
"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",
"docs_filter_label_tags": "Schlagworte",
"docs_filter_label_sender": "Absender",
"docs_filter_label_receivers": "Empfänger",
"docs_filter_label_from": "Von",
"docs_filter_label_to": "Bis",
"docs_btn_new": "Neues Dokument",
"docs_empty_heading": "Keine Dokumente gefunden",
"docs_empty_text": "Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.",
"docs_empty_btn_clear": "Alle Filter löschen",
"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",
"doc_file_upload_label": "Datei hochladen",
"doc_file_upload_note": "(optional)",
"doc_file_replace_label": "Neue Datei hochladen",
"doc_file_replace_note": "(ersetzt die aktuelle Datei)",
"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",
"doc_label_archive_location_original": "Aufbewahrungsort (Original)",
"doc_section_persons": "Personen",
"doc_sender_not_specified": "Nicht angegeben",
"doc_no_receivers": "Keine Empfänger",
"doc_section_content": "Inhalt",
"doc_label_summary": "Zusammenfassung",
"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",
"person_merge_description": "Diese Person wird in die gewählte Zielperson überführt. Alle Dokumente und Verknüpfungen werden übertragen, danach wird diese Person gelöscht.",
"person_merge_target_label": "Zusammenführen mit",
"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)",
"conv_label_person_b": "Person B (Empfänger)",
"conv_label_from": "Zeitraum von",
"conv_label_to": "Zeitraum bis",
"conv_sort_label": "Sortierung:",
"conv_sort_newest": "Neueste zuerst",
"conv_sort_oldest": "Älteste zuerst",
"conv_empty_heading": "Wählen Sie zwei Personen aus",
"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",
"admin_tab_tags": "Schlagworte",
"admin_section_users": "Benutzerverwaltung",
"admin_col_login": "Login",
"admin_col_groups": "Gruppen",
"admin_col_password": "Passwort",
"admin_multiselect_hint": "Strg+Klick für Auswahl",
"admin_password_placeholder": "Neues PW (optional)",
"admin_no_groups": "Keine Gruppen",
"admin_btn_delete_user_title": "Benutzer löschen",
"admin_section_new_user": "Neuen Benutzer anlegen",
"admin_multiselect_hint_multi": "Strg+Klick für mehrere",
"admin_multiselect_hint_full": "Strg+Klick für Mehrfachauswahl",
"admin_section_tags": "Schlagworte",
"admin_tags_warning": "Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.",
"admin_btn_edit_tag_label": "Schlagwort bearbeiten",
"admin_tag_delete_confirm": "Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.",
"admin_btn_delete_tag_label": "Schlagwort löschen",
"admin_section_groups": "Gruppenverwaltung",
"admin_col_name": "Name",
"admin_col_permissions": "Berechtigungen",
"admin_col_actions": "Aktionen",
"admin_group_delete_confirm": "Gruppe wirklich löschen?",
"admin_section_new_group": "Neue Gruppe anlegen",
"admin_group_name_placeholder": "Gruppenname (z.B. Editoren)",
"admin_user_delete_confirm": "Benutzer {username} wirklich löschen?",
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
"doc_download_title": "Herunterladen",
"doc_tag_filter_title": "Nach {name} filtern",
"doc_conversation_title": "Konversation anzeigen",
"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...",
"comp_multiselect_remove": "Entfernen",
"comp_multiselect_loading": "Suche...",
"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.",
"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"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from en!",
"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.",
@@ -10,5 +9,190 @@
"error_unauthorized": "You are not logged in.",
"error_forbidden": "You do not have permission for this action.",
"error_validation_error": "The input is invalid.",
"error_internal_error": "An unexpected error occurred."
"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",
"btn_create": "Create",
"btn_delete": "Delete",
"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",
"form_placeholder_alias": "e.g. Grandma Frieda, Uncle Karl…",
"form_label_date": "Date",
"form_placeholder_date": "DD.MM.YYYY",
"form_date_error": "Please enter in DD.MM.YYYY format, e.g. 20.12.2026",
"form_label_location": "Location",
"form_placeholder_location": "e.g. Berlin, Vienna…",
"form_label_sender": "Sender",
"form_label_receivers": "Recipients",
"form_label_title": "Title *",
"form_label_tags": "Tags",
"form_label_content": "Content",
"form_placeholder_content": "Brief description of the content…",
"form_label_transcription": "Transcription",
"form_placeholder_transcription": "Full text of the document…",
"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",
"docs_filter_label_tags": "Tags",
"docs_filter_label_sender": "Sender",
"docs_filter_label_receivers": "Recipients",
"docs_filter_label_from": "From",
"docs_filter_label_to": "To",
"docs_btn_new": "New document",
"docs_empty_heading": "No documents found",
"docs_empty_text": "Try adjusting the filters or changing the search term.",
"docs_empty_btn_clear": "Clear all filters",
"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",
"doc_file_upload_label": "Upload file",
"doc_file_upload_note": "(optional)",
"doc_file_replace_label": "Upload new file",
"doc_file_replace_note": "(replaces the current file)",
"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",
"doc_label_archive_location_original": "Storage location (original)",
"doc_section_persons": "Persons",
"doc_sender_not_specified": "Not specified",
"doc_no_receivers": "No recipients",
"doc_section_content": "Content",
"doc_label_summary": "Summary",
"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",
"person_merge_description": "This person will be merged into the selected target person. All documents and links will be transferred, then this person will be deleted.",
"person_merge_target_label": "Merge with",
"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)",
"conv_label_person_b": "Person B (Recipient)",
"conv_label_from": "Period from",
"conv_label_to": "Period to",
"conv_sort_label": "Sort:",
"conv_sort_newest": "Newest first",
"conv_sort_oldest": "Oldest first",
"conv_empty_heading": "Select two persons",
"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",
"admin_tab_tags": "Tags",
"admin_section_users": "User management",
"admin_col_login": "Login",
"admin_col_groups": "Groups",
"admin_col_password": "Password",
"admin_multiselect_hint": "Ctrl+Click to select",
"admin_password_placeholder": "New PW (optional)",
"admin_no_groups": "No groups",
"admin_btn_delete_user_title": "Delete user",
"admin_section_new_user": "Create new user",
"admin_multiselect_hint_multi": "Ctrl+Click for multiple",
"admin_multiselect_hint_full": "Ctrl+Click for multiple selection",
"admin_section_tags": "Tags",
"admin_tags_warning": "Warning: Renaming or deleting affects all linked documents.",
"admin_btn_edit_tag_label": "Edit tag",
"admin_tag_delete_confirm": "Really delete? The tag will be removed from all documents.",
"admin_btn_delete_tag_label": "Delete tag",
"admin_section_groups": "Group management",
"admin_col_name": "Name",
"admin_col_permissions": "Permissions",
"admin_col_actions": "Actions",
"admin_group_delete_confirm": "Really delete group?",
"admin_section_new_group": "Create new group",
"admin_group_name_placeholder": "Group name (e.g. Editors)",
"admin_user_delete_confirm": "Really delete user {username}?",
"doc_file_error_preview": "Could not load preview.",
"doc_download_title": "Download",
"doc_tag_filter_title": "Filter by {name}",
"doc_conversation_title": "Show conversation",
"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...",
"comp_multiselect_remove": "Remove",
"comp_multiselect_loading": "Searching...",
"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.",
"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"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from es!",
"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.",
@@ -10,5 +9,190 @@
"error_unauthorized": "No ha iniciado sesión.",
"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."
"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",
"btn_create": "Crear",
"btn_delete": "Eliminar",
"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",
"form_placeholder_alias": "p.ej. Abuela Frieda, Tío Karl…",
"form_label_date": "Fecha",
"form_placeholder_date": "DD.MM.AAAA",
"form_date_error": "Introduzca en formato DD.MM.AAAA, p.ej. 20.12.2026",
"form_label_location": "Lugar",
"form_placeholder_location": "p.ej. Berlín, Viena…",
"form_label_sender": "Remitente",
"form_label_receivers": "Destinatarios",
"form_label_title": "Título *",
"form_label_tags": "Etiquetas",
"form_label_content": "Contenido",
"form_placeholder_content": "Breve descripción del contenido…",
"form_label_transcription": "Transcripción",
"form_placeholder_transcription": "Texto completo del documento…",
"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",
"docs_filter_label_tags": "Etiquetas",
"docs_filter_label_sender": "Remitente",
"docs_filter_label_receivers": "Destinatarios",
"docs_filter_label_from": "Desde",
"docs_filter_label_to": "Hasta",
"docs_btn_new": "Nuevo documento",
"docs_empty_heading": "No se encontraron documentos",
"docs_empty_text": "Intente ajustar los filtros o cambiar el término de búsqueda.",
"docs_empty_btn_clear": "Borrar todos los filtros",
"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",
"doc_file_upload_label": "Subir archivo",
"doc_file_upload_note": "(opcional)",
"doc_file_replace_label": "Subir nuevo archivo",
"doc_file_replace_note": "(reemplaza el archivo actual)",
"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",
"doc_label_archive_location_original": "Ubicación de almacenamiento (original)",
"doc_section_persons": "Personas",
"doc_sender_not_specified": "No especificado",
"doc_no_receivers": "Sin destinatarios",
"doc_section_content": "Contenido",
"doc_label_summary": "Resumen",
"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",
"person_merge_description": "Esta persona se fusionará con la persona de destino seleccionada. Todos los documentos y enlaces se transferirán y esta persona será eliminada.",
"person_merge_target_label": "Fusionar con",
"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)",
"conv_label_person_b": "Persona B (Destinatario)",
"conv_label_from": "Período desde",
"conv_label_to": "Período hasta",
"conv_sort_label": "Ordenar:",
"conv_sort_newest": "Más reciente primero",
"conv_sort_oldest": "Más antiguo primero",
"conv_empty_heading": "Seleccione dos personas",
"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",
"admin_tab_tags": "Etiquetas",
"admin_section_users": "Gestión de usuarios",
"admin_col_login": "Login",
"admin_col_groups": "Grupos",
"admin_col_password": "Contraseña",
"admin_multiselect_hint": "Ctrl+Clic para seleccionar",
"admin_password_placeholder": "Nueva contraseña (opcional)",
"admin_no_groups": "Sin grupos",
"admin_btn_delete_user_title": "Eliminar usuario",
"admin_section_new_user": "Crear nuevo usuario",
"admin_multiselect_hint_multi": "Ctrl+Clic para varios",
"admin_multiselect_hint_full": "Ctrl+Clic para selección múltiple",
"admin_section_tags": "Etiquetas",
"admin_tags_warning": "Advertencia: Renombrar o eliminar afecta a todos los documentos vinculados.",
"admin_btn_edit_tag_label": "Editar etiqueta",
"admin_tag_delete_confirm": "¿Realmente eliminar? La etiqueta se eliminará de todos los documentos.",
"admin_btn_delete_tag_label": "Eliminar etiqueta",
"admin_section_groups": "Gestión de grupos",
"admin_col_name": "Nombre",
"admin_col_permissions": "Permisos",
"admin_col_actions": "Acciones",
"admin_group_delete_confirm": "¿Realmente eliminar el grupo?",
"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}?",
"doc_file_error_preview": "No se pudo cargar la vista previa.",
"doc_download_title": "Descargar",
"doc_tag_filter_title": "Filtrar por {name}",
"doc_conversation_title": "Ver conversación",
"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...",
"comp_multiselect_remove": "Eliminar",
"comp_multiselect_loading": "Buscando...",
"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.",
"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"
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,23 +7,26 @@
"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 .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
},
"dependencies": {
"openapi-fetch": "^0.13.5"
},
"devDependencies": {
"openapi-typescript": "^7.8.0",
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@inlang/paraglide-js": "^2.5.0",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
@@ -36,6 +39,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.5.0",
"openapi-typescript": "^7.8.0",
"playwright": "^1.56.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",

View File

@@ -0,0 +1,49 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
testDir: './e2e',
// Auto-starts the SvelteKit dev server before E2E tests.
// Reuses the existing server if already running (e.g. during active development).
// The backend + DB + MinIO must be started separately (see README or CI workflow).
webServer: {
command: 'npm run dev -- --port 3000',
url: 'http://localhost:3000',
reuseExistingServer: true,
timeout: 120_000
},
fullyParallel: false, // tests share auth state → run sequentially within a worker
retries: process.env.CI ? 2 : 0,
workers: 1,
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German
screenshot: 'on', // always capture screenshots
video: 'retain-on-failure',
trace: 'retain-on-failure'
},
projects: [
// 1. Auth setup: logs in and saves the session cookie to disk
{
name: 'setup',
testMatch: /auth\.setup\.ts/
},
// 2. All E2E tests, re-using the stored session
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: path.join(__dirname, 'e2e/.auth/user.json')
},
dependencies: ['setup']
}
],
outputDir: 'test-results/e2e'
});

View File

@@ -7,10 +7,6 @@
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"baseLocale": "en",
"locales": [
"en",
"es",
"de"
]
"baseLocale": "de",
"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

@@ -2,65 +2,96 @@ import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { sequence } from '@sveltejs/kit/hooks';
import { env } from 'process';
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
import { detectLocale } from '$lib/server/locale';
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
const PUBLIC_PATHS = ['/login', '/logout'];
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
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
});
}
}
return resolve(event);
};
const handleAuth: Handle = async ({ event, resolve }) => {
const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p));
if (!isPublic && !event.locals.user) {
throw redirect(302, '/login');
}
return resolve(event);
};
const handleParaglide: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
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 response = await fetch('http://localhost:8080/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) {
throw redirect(302, '/login');
}
const token = event.cookies.get('auth_token');
// Clone the request first to preserve the body
const clonedRequest = request.clone();
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
// Create new request with Authorization header and preserved body
const modifiedRequest = new Request(clonedRequest, {
headers: {
...Object.fromEntries(clonedRequest.headers),
'Authorization': token
}
});
// Clone the request first to preserve the body
const clonedRequest = request.clone();
return fetch(modifiedRequest);
}
// Create new request with Authorization header and preserved body
const modifiedRequest = new Request(clonedRequest, {
headers: {
...Object.fromEntries(clonedRequest.headers),
Authorization: token
}
});
return fetch(request);
return fetch(modifiedRequest);
}
return fetch(request);
};
export const handle = sequence(userGroup, handleParaglide);
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

@@ -1,117 +1,143 @@
<script lang="ts">
type Person = { id?: string; firstName?: string; lastName?: string; alias?: string };
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
export let selectedPersons: Person[] = [];
interface Props {
selectedPersons?: Person[];
}
let searchTerm = '';
let results: Person[] = [];
let showDropdown = false;
let loading = false;
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = '';
let { selectedPersons = $bindable([]) }: Props = $props();
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`;
}
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 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 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 selectPerson(person: Person) {
selectedPersons = [...selectedPersons, person];
searchTerm = '';
showDropdown = false;
results = [];
}
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 removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter(p => p.id !== id);
}
function selectPerson(person: Person) {
selectedPersons = [...selectedPersons, person];
searchTerm = '';
showDropdown = false;
results = [];
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !(e as Event).defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return { destroy() { document.removeEventListener('click', handleClick, true); } };
}
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);
}
};
}
</script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
<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"
on:click={() => removePerson(person.id)}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
aria-label="Entfernen"
>
<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}
on:input={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder={selectedPersons.length === 0 ? 'Namen tippen...' : ''}
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">Suche...</div>
{:else}
{#each results as person}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
on:click={() => 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

@@ -0,0 +1,185 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonMultiSelect from './PersonMultiSelect.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' },
{ id: '3', firstName: 'Karl', lastName: 'König' }
];
function mockFetch(persons = PERSONS) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(persons)
})
);
}
function receiverInputs() {
return Array.from(
document.querySelectorAll<HTMLInputElement>('input[type="hidden"][name="receiverIds"]')
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMultiSelect rendering', () => {
it('renders the text input with placeholder when no persons selected', async () => {
render(PersonMultiSelect, { selectedPersons: [] });
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-empty.png' });
});
it('renders pre-selected persons as chips', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
]
});
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-with-chips.png' });
});
it('renders hidden inputs for each selected person', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
]
});
await tick();
const inputs = receiverInputs();
expect(inputs).toHaveLength(2);
expect(inputs[0].value).toBe('1');
expect(inputs[1].value).toBe('2');
});
it('hides the placeholder when persons are selected', async () => {
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
});
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
});
});
// ─── Selecting persons ────────────────────────────────────────────────────────
describe('PersonMultiSelect selecting persons', () => {
it('adds a person chip on result click', async () => {
mockFetch();
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({
path: 'test-results/screenshots/person-multiselect-one-selected.png'
});
});
it('can select multiple persons sequentially', async () => {
mockFetch();
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Musterfrau, Anna').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({
path: 'test-results/screenshots/person-multiselect-two-selected.png'
});
});
it('filters already-selected persons from search results', async () => {
mockFetch();
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
});
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
});
it('selects a result with Enter key', async () => {
mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Ma');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
});
});
// ─── Removing persons ─────────────────────────────────────────────────────────
describe('PersonMultiSelect removing persons', () => {
it('removes a chip when its × button is clicked', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
]
});
// Buttons have aria-label="Entfernen"
const removeButtons = page.getByRole('button', { name: 'Entfernen' });
await removeButtons.first().click();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
});
it('removes the corresponding hidden input when a chip is removed', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
]
});
await page.getByRole('button', { name: 'Entfernen' }).first().click();
await tick();
const inputs = receiverInputs();
expect(inputs).toHaveLength(1);
expect(inputs[0].value).toBe('2');
});
});
// ─── Click outside ────────────────────────────────────────────────────────────
describe('PersonMultiSelect click outside', () => {
it('closes the dropdown when clicking outside', async () => {
mockFetch();
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
});
});

View File

@@ -1,131 +1,154 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
// Props
export let name: string;
export let label: string;
export let value: string = "";
export let initialName: string = "";
interface Props {
name: string;
label: string;
value?: string;
initialName?: string;
restrictToCorrespondentsOf?: string;
onchange?: (value: string) => void;
}
const dispatch = createEventDispatcher();
let {
name,
label,
value = $bindable(''),
initialName = '',
restrictToCorrespondentsOf,
onchange
}: Props = $props();
// Lokaler State
let searchTerm = initialName;
let searchTerm = $state(initialName);
// Sync mit externen Änderungen (z.B. Reset Button)
$: searchTerm = initialName;
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let results: any[] = [];
let showDropdown = false;
let loading = false;
let debounceTimer: any;
function handleInput() {
if (value && searchTerm !== initialName) {
value = '';
onchange?.('');
}
function handleInput() {
// Wenn der User tippt, ist die alte ID ungültig -> Reset
if (value && searchTerm !== initialName) {
value = "";
dispatch('change', { value: "" }); // Bescheid geben: Auswahl aufgehoben
}
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)}`);
if (res.ok) {
results = await res.json();
} else {
results = [];
}
} 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: any) {
value = person.id;
searchTerm = `${person.firstName} ${person.lastName}`;
showDropdown = false;
function selectPerson(person: Person) {
value = person.id!;
searchTerm = `${person.firstName} ${person.lastName}`;
showDropdown = false;
onchange?.(person.id!);
}
// --- NEU: Event feuern ---
dispatch('change', { value: person.id });
}
let inputEl: HTMLInputElement;
let dropdownStyle = '';
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: any) => {
if (node && !node.contains(event.target) && !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 on:scroll={updateDropdownPosition} on:resize={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}
on:input={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder="Namen tippen..."
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">Suche...</div>
{:else}
{#each results as person}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
on:click={() => 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

@@ -0,0 +1,267 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
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));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
];
function mockFetchWithPersons(persons = PERSONS) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(persons)
})
);
}
function mockFetchFailure() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
}
function hiddenInput(name: string) {
return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonTypeahead rendering', () => {
it('renders the label and text input', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
await expect.element(page.getByText('Absender')).toBeInTheDocument();
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-empty.png' });
});
it('pre-fills the visible input from initialName', async () => {
render(PersonTypeahead, {
name: 'senderId',
label: 'Absender',
initialName: 'Max Mustermann'
});
// The $effect that syncs initialName runs after mount — poll until the value appears
await expect.element(page.getByPlaceholder('Namen tippen...')).toHaveValue('Max Mustermann');
});
it('renders a hidden input with the correct name attribute', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
await tick();
expect(hiddenInput('senderId')).toBeTruthy();
});
it('hidden input starts with the provided value', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender', value: '42' });
await tick();
expect(hiddenInput('senderId')?.value).toBe('42');
});
});
// ─── Search ───────────────────────────────────────────────────────────────────
describe('PersonTypeahead search', () => {
it('opens the dropdown with results after typing', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
});
it('shows loading indicator while fetching', async () => {
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await expect.element(page.getByText('Suche...')).toBeInTheDocument();
});
it('shows no dropdown when the search returns empty results', async () => {
mockFetchWithPersons([]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('XYZ');
await waitForDebounce();
await expect.element(page.getByText('Suche...')).not.toBeInTheDocument();
});
it('shows no results when fetch fails', async () => {
mockFetchFailure();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
});
});
// ─── Selection ────────────────────────────────────────────────────────────────
describe('PersonTypeahead selection', () => {
it('fills the visible input and closes dropdown on click', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
.not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
it('sets the hidden input value to the selected person id', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await tick();
expect(hiddenInput('senderId')?.value).toBe('1');
});
it('calls onchange with the person id on selection', async () => {
mockFetchWithPersons();
const onchange = vi.fn();
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
});
it('selects a result with Enter key', async () => {
mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
});
});
// ─── Clearing ─────────────────────────────────────────────────────────────────
describe('PersonTypeahead clearing a selection', () => {
it('clears the hidden value when user edits the visible input after a selection', async () => {
mockFetchWithPersons();
const onchange = vi.fn();
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
onchange.mockClear();
await input.fill('x');
await waitForDebounce();
expect(onchange).toHaveBeenCalledWith('');
await tick();
expect(hiddenInput('senderId')?.value).toBe('');
});
});
// ─── 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', () => {
it('closes the dropdown when clicking outside the component', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
});
});

View File

@@ -1,89 +1,105 @@
<script lang="ts">
export let tags: string[] = []; // Two-way binding
export let allowCreation = true;
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
let inputVal = '';
let suggestions: string[] = [];
let activeIndex = -1;
let showSuggestions = false;
interface Props {
tags?: string[];
allowCreation?: boolean;
}
// Fetch suggestions from backend
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();
// API returns Tag objects with { id, name }
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);
}
}
let { tags = $bindable([]), allowCreation = true }: Props = $props();
function addTag(tag: string) {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed)) {
tags = [...tags, trimmed];
}
inputVal = '';
suggestions = [];
showSuggestions = false;
activeIndex = -1;
}
let inputVal = $state('');
let suggestions: string[] = $state([]);
let activeIndex = $state(-1);
let showSuggestions = $state(false);
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
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 handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
addTag(suggestions[activeIndex]);
} else if(allowCreation) {
addTag(inputVal); // Add new tag
}
} 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 addTag(tag: string) {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed)) {
tags = [...tags, trimmed];
}
inputVal = '';
suggestions = [];
showSuggestions = false;
activeIndex = -1;
}
function handleInput() {
fetchSuggestions(inputVal);
}
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 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">
<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]"
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}
{#each tags as tag, i (i)}
<span
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
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"
on:click={() => removeTag(i)}
aria-label="Schlagwort entfernen"
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"
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
@@ -96,37 +112,36 @@
{/each}
<!-- Input Field -->
<div class="relative flex-1 min-w-[120px]">
<div class="relative min-w-[120px] flex-1">
<input
type="text"
bind:value={inputVal}
on:input={handleInput}
on:keydown={handleKeydown}
on:focus={() => handleInput()}
on:blur={() => setTimeout(() => (showSuggestions = false), 200)}
oninput={() => fetchSuggestions(inputVal)}
onkeydown={handleKeydown}
onfocus={() => fetchSuggestions(inputVal)}
placeholder={tags.length === 0
? allowCreation
? 'Schlagworte hinzufügen...'
: 'Nach Schlagworten filtern...'
: ''}
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
? allowCreation
? m.comp_taginput_placeholder_create()
: m.comp_taginput_placeholder_filter()
: ''}
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"
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}
{#each suggestions as suggestion, i (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'
: 'text-gray-700'}"
on:click={() => addTag(suggestion)}
on:keydown={(e) => e.key === 'Enter' && addTag(suggestion)}
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>
@@ -136,6 +151,6 @@
</div>
</div>
{#if allowCreation}
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p>
{/if}
</div>

View File

@@ -0,0 +1,210 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import TagInput from './TagInput.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
function mockFetchWithTags(tagNames: string[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(tagNames.map((name) => ({ name })))
})
);
}
function mockFetchEmpty() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
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 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();
});
it('renders existing tags as chips', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
await expect.element(page.getByText('Familie')).toBeInTheDocument();
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
});
it('hides input placeholder once tags exist', async () => {
render(TagInput, { tags: ['Familie'], allowCreation: true });
const input = page.getByRole('textbox');
await expect.element(input).toHaveAttribute('placeholder', '');
});
it('shows the "Enter" hint when allowCreation=true', async () => {
render(TagInput, { tags: [], allowCreation: true });
await expect.element(page.getByText(/Enter drücken/i)).toBeInTheDocument();
});
it('hides the "Enter" hint when allowCreation=false', async () => {
render(TagInput, { tags: [], allowCreation: false });
await expect.element(page.getByText(/Enter drücken/i)).not.toBeInTheDocument();
});
});
// ─── Adding tags ──────────────────────────────────────────────────────────────
describe('TagInput adding tags', () => {
it('adds a tag on Enter and clears the input', async () => {
mockFetchEmpty();
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Urlaubsreise');
await userEvent.keyboard('{Enter}');
await expect.element(page.getByText('Urlaubsreise')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
});
it('trims whitespace from the new tag', async () => {
mockFetchEmpty();
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill(' Leerzeichen ');
await userEvent.keyboard('{Enter}');
await expect.element(page.getByText('Leerzeichen')).toBeInTheDocument();
});
it('does not add a duplicate tag', async () => {
mockFetchEmpty();
render(TagInput, { tags: ['Familie'], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Familie');
await userEvent.keyboard('{Enter}');
await expect.element(input).toHaveValue('');
await expect.element(page.getByText('Familie')).toBeInTheDocument();
});
it('does not add an arbitrary tag when allowCreation=false', async () => {
mockFetchEmpty();
render(TagInput, { tags: [], allowCreation: false });
const input = page.getByRole('textbox');
await input.fill('UnbekannterTag');
await userEvent.keyboard('{Enter}');
await expect.element(page.getByText('UnbekannterTag')).not.toBeInTheDocument();
});
});
// ─── Removing tags ────────────────────────────────────────────────────────────
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"
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' });
});
it('removes the last tag on Backspace when the input is empty', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
(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();
});
it('does not remove a tag on Backspace when the input has text', async () => {
render(TagInput, { tags: ['Familie'], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('x');
await userEvent.keyboard('{Backspace}');
await expect.element(page.getByText('Familie')).toBeInTheDocument();
});
});
// ─── Autocomplete ─────────────────────────────────────────────────────────────
describe('TagInput autocomplete', () => {
it('shows suggestions after typing 2+ characters', async () => {
mockFetchWithTags(['Familie', 'Freunde']);
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Fa');
await waitForDebounce();
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestions.png' });
});
it('does not call fetch for fewer than 2 characters', async () => {
mockFetchEmpty();
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('F');
await waitForDebounce();
expect(fetch).not.toHaveBeenCalled();
});
it('filters already-selected tags out of suggestions', async () => {
mockFetchWithTags(['Familie', 'Freunde']);
render(TagInput, { tags: ['Familie'], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Fr');
await waitForDebounce();
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
});
it('selects a suggestion on click and adds it as a chip', async () => {
mockFetchWithTags(['Familie', 'Freunde']);
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Fa');
await waitForDebounce();
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' });
});
it('navigates suggestions with ArrowDown and selects with Enter', async () => {
mockFetchWithTags(['Aachen', 'Berlin', 'Celle']);
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('__');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}'); // index 0 → Aachen
await userEvent.keyboard('{ArrowDown}'); // index 1 → Berlin
await userEvent.keyboard('{Enter}');
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
});
it('hides the dropdown when clicking outside the component', async () => {
mockFetchWithTags(['Familie']);
render(TagInput, { tags: [], allowCreation: true });
const input = page.getByRole('textbox');
await input.fill('Fa');
await waitForDebounce();
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
});
});

View File

@@ -5,20 +5,22 @@ 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'
| '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 +28,43 @@ 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 '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,22 @@
*/
export interface paths {
"/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 +84,38 @@ 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;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getPersons"];
put?: never;
post: operations["createPerson"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/merge": {
parameters: {
query?: never;
@@ -148,17 +196,17 @@ export interface paths {
patch: operations["updateGroup"];
trace?: never;
};
"/api/users/me": {
"/api/users/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getCurrentUser"];
get: operations["getUser"];
put?: never;
post?: never;
delete?: never;
delete: operations["deleteUser"];
options?: never;
head?: never;
patch?: never;
@@ -180,14 +228,14 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons": {
"/api/persons/{id}/received-documents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getPersons"];
get: operations["getPersonReceivedDocuments"];
put?: never;
post?: never;
delete?: never;
@@ -212,6 +260,22 @@ 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}/file": {
parameters: {
query?: never;
@@ -276,37 +340,66 @@ 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: {
UpdateProfileDTO: {
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email?: string;
contact?: 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[];
};
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;
@@ -352,22 +445,9 @@ export interface components {
initialPassword?: string;
groupIds?: 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;
@@ -391,6 +471,50 @@ export interface components {
}
export type $defs = Record<string, never>;
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"];
};
};
};
};
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 +596,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": {
[key: string]: string;
};
"application/json": components["schemas"]["PersonUpdateDTO"];
};
};
responses: {
@@ -581,6 +703,76 @@ 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?: {
q?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"][];
};
};
};
};
createPerson: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
[key: string]: string;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"];
};
};
};
};
mergePerson: {
parameters: {
query?: never;
@@ -741,11 +933,13 @@ export interface operations {
};
};
};
getCurrentUser: {
getUser: {
parameters: {
query?: never;
header?: never;
path?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
@@ -761,6 +955,26 @@ 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;
};
};
};
searchTags: {
parameters: {
query?: {
@@ -783,13 +997,13 @@ export interface operations {
};
};
};
getPersons: {
getPersonReceivedDocuments: {
parameters: {
query?: {
q?: string;
};
query?: never;
header?: never;
path?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
@@ -800,7 +1014,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"][];
"*/*": components["schemas"]["Document"][];
};
};
};
@@ -827,6 +1041,30 @@ 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"][];
};
};
};
};
getDocumentFile: {
parameters: {
query?: never;
@@ -922,24 +1160,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,32 @@
import { describe, expect, it } from 'vitest';
import { detectLocale } from './locale';
describe('detectLocale', () => {
it('returns de for a German browser', () => {
expect(detectLocale('de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7')).toBe('de');
});
it('returns en for an English browser', () => {
expect(detectLocale('en-US,en;q=0.9')).toBe('en');
});
it('returns es for a Spanish browser', () => {
expect(detectLocale('es-MX,es;q=0.9,en-US;q=0.8')).toBe('es');
});
it('falls back to a supported language when the primary is unsupported', () => {
expect(detectLocale('fr-FR,fr;q=0.9,en;q=0.8')).toBe('en');
});
it('respects quality values — picks the highest-priority supported locale', () => {
expect(detectLocale('en-US;q=0.7,de-DE;q=0.9')).toBe('de');
});
it('returns null for a completely unsupported language', () => {
expect(detectLocale('ja-JP,ja;q=0.9,zh-CN;q=0.8')).toBeNull();
});
it('returns null for an empty header', () => {
expect(detectLocale('')).toBeNull();
});
});

View File

@@ -0,0 +1,20 @@
import { locales } from '$lib/paraglide/runtime';
/**
* Picks the best supported locale from an Accept-Language header value.
* Returns null when no supported locale is found.
*/
export function detectLocale(acceptLanguage: string): string | null {
const preferred = acceptLanguage
.split(',')
.map((part) => {
const [lang, q] = part.trim().split(';q=');
return { lang: lang.trim().split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 };
})
.sort((a, b) => b.q - a.q);
for (const { lang } of preferred) {
if ((locales as readonly string[]).includes(lang)) return lang;
}
return null;
}

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { germanToIso, isoToGerman } from './utils';
describe('isoToGerman', () => {
it('converts a standard ISO date', () => {
expect(isoToGerman('2024-03-15')).toBe('15.03.2024');
});
it('preserves leading zeros for day and month', () => {
expect(isoToGerman('2024-01-05')).toBe('05.01.2024');
});
it('handles December 31', () => {
expect(isoToGerman('1945-12-31')).toBe('31.12.1945');
});
it('returns empty string for empty input', () => {
expect(isoToGerman('')).toBe('');
});
it('returns empty string for plain text', () => {
expect(isoToGerman('not-a-date')).toBe('');
});
it('returns empty string for partial ISO string', () => {
expect(isoToGerman('2024-03')).toBe('');
});
it('returns empty string for ISO with time component', () => {
expect(isoToGerman('2024-03-15T12:00:00')).toBe('');
});
});
describe('germanToIso', () => {
it('converts a standard German date', () => {
expect(germanToIso('15.03.2024')).toBe('2024-03-15');
});
it('preserves leading zeros for day and month', () => {
expect(germanToIso('05.01.2024')).toBe('2024-01-05');
});
it('handles December 31', () => {
expect(germanToIso('31.12.1945')).toBe('1945-12-31');
});
it('returns empty string for empty input', () => {
expect(germanToIso('')).toBe('');
});
it('returns empty string for plain text', () => {
expect(germanToIso('not-a-date')).toBe('');
});
it('returns empty string for date without leading zeros', () => {
expect(germanToIso('5.3.2024')).toBe('');
});
it('returns empty string for ISO format input', () => {
expect(germanToIso('2024-03-15')).toBe('');
});
it('returns empty string for partial German date', () => {
expect(germanToIso('15.03')).toBe('');
});
});
describe('round-trip conversion', () => {
const dates = ['2024-03-15', '1945-01-01', '2000-12-31', '1899-07-04'];
for (const date of dates) {
it(`ISO → German → ISO is identity for ${date}`, () => {
expect(germanToIso(isoToGerman(date))).toBe(date);
});
}
it('German → ISO → German is identity', () => {
expect(isoToGerman(germanToIso('20.04.1889'))).toBe('20.04.1889');
});
});

20
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
* Returns an empty string for invalid or empty input.
*/
export function isoToGerman(iso: string): string {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
}
/**
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
* Returns an empty string for invalid or empty input.
*/
export function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
const [, d, m, y] = match;
return `${y}-${m}-${d}`;
}

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

@@ -0,0 +1,44 @@
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 };
describe('sortDocumentsByDate', () => {
it('sorts DESC by default — newest first', () => {
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
const result = sortDocumentsByDate(docs, 'DESC');
expect(result.map((d) => d.id)).toEqual(['b', 'c', 'a']);
});
it('sorts ASC — oldest first', () => {
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
const result = sortDocumentsByDate(docs, 'ASC');
expect(result.map((d) => d.id)).toEqual(['a', 'c', 'b']);
});
it('places documents without a date last in DESC', () => {
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
const result = sortDocumentsByDate(docs, 'DESC');
expect(result[0].id).toBe('b');
expect(result.slice(1).map((d) => d.id)).toContain('a');
expect(result.slice(1).map((d) => d.id)).toContain('c');
});
it('places documents without a date last in ASC', () => {
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
const result = sortDocumentsByDate(docs, 'ASC');
expect(result[0].id).toBe('b');
});
it('does not mutate the original array', () => {
const docs = [doc('a', '1950-01-01'), doc('b', '1920-01-01')];
const original = [...docs];
sortDocumentsByDate(docs, 'ASC');
expect(docs).toEqual(original);
});
it('returns an empty array unchanged', () => {
expect(sortDocumentsByDate([], 'DESC')).toEqual([]);
});
});

View File

@@ -0,0 +1,19 @@
export type SortDir = 'ASC' | 'DESC';
/**
* Returns a new array of documents sorted by documentDate.
* Documents without a date are always placed last, regardless of direction.
*/
export function sortDocumentsByDate<T extends { documentDate?: string | null }>(
docs: T[],
dir: SortDir
): T[] {
return [...docs].sort((a, b) => {
const da = a.documentDate ?? '';
const db = b.documentDate ?? '';
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return dir === 'DESC' ? db.localeCompare(da) : da.localeCompare(db);
});
}

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,110 +1,189 @@
<script lang="ts">
import './layout.css';
import { enhance } from '$app/forms';
import { page } from '$app/state';
$: user = page.data.user;
$: isAdmin = user?.groups.some(g => g.permissions.includes("ADMIN"))
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, 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 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;
});
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-brand-sand">
<!-- Changed background to Sand -->
<!-- Corporate Header -->
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
{#if !page.url.pathname.startsWith('/login')}
<header class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-20">
<!-- Slightly taller header -->
<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>
<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">
<!-- LOGO (Extracted from their SVG) -->
<div class="flex-shrink-0 flex items-center mr-8">
<a href="/" class="flex items-center gap-2" aria-label="Familienarchiv">
<!-- SVG Code from their site -->
<svg
width="250"
height="25"
viewBox="0 0 250 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
<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
>
<path
d="M0.156128 1.01562C5.375 1.43431 9.621 4.65591 9.621 11.6669V19.8779H0.156128V10.4467H5.76852C5.76852 5.6736 3.70661 3.72129 0.156128 2.8334V1.01562Z"
fill="#B4B9FF"
></path>
<path
d="M10.5892 19.8779C15.8076 19.4592 20.0541 16.2371 20.0541 9.22655V1.01562H10.5892V10.4467H16.2012C16.2012 15.2199 14.1397 17.1722 10.5892 18.0601V19.8779Z"
fill="#B4B9FF"
></path>
<text
x="35"
y="20"
fill="#002850"
font-family="Montserrat"
font-weight="bold"
font-size="20">FAMILIENARCHIV</text
>
</svg>
</a>
</div>
<!-- Nav Links (Montserrat font, Uppercase style often used in corporate) -->
<div class="hidden sm:ml-6 sm:flex sm:space-x-8 items-center">
<nav class="hidden items-center sm:flex sm:space-x-1">
<a
href="/"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
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')
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
Dokumente
{m.nav_documents()}
</a>
<a
href="/persons"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
{page.url.pathname.startsWith('/persons')
? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
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')
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
Personen
{m.nav_persons()}
</a>
<a
href="/conversations"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
{page.url.pathname.startsWith('/conversations')
? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
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')
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
Konversationen
{m.nav_conversations()}
</a>
{#if isAdmin}
<a
href="/admin"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-bold uppercase tracking-wide font-sans
{page.url.pathname.startsWith('/admin')
? 'border-brand-navy text-brand-navy'
: 'border-transparent text-gray-500 hover:border-brand-light hover:text-brand-navy'}"
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')
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
Admin
{m.nav_admin()}
</a>
{/if}
</div>
</nav>
</div>
<!-- Right Side -->
<div class="flex items-center">
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="text-sm text-gray-500 hover:text-brand-navy font-bold uppercase font-sans tracking-wide px-3 py-2 transition"
>
Abmelden
</button>
</form>
<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>
<!-- 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}
{#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>
@@ -112,6 +191,6 @@
{/if}
<main class="py-6">
<slot />
{@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
};
}
}

View File

@@ -3,24 +3,34 @@ import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { goto } from '$app/navigation';
import TagInput from '$lib/components/TagInput.svelte';
import { slide } from 'svelte/transition';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
export let data;
let { data } = $props();
// Local state variables
let q = data.filters?.q || '';
let from = data.filters?.from || '';
let to = data.filters?.to || '';
let senderId = data.filters?.senderId || '';
let receiverId = data.filters?.receiverId || '';
let tagNames = data.filters?.tags || [];
let q = $state(untrack(() => data.filters?.q || ''));
let qFocused = $state(false);
let from = $state(untrack(() => data.filters?.from || ''));
let to = $state(untrack(() => data.filters?.to || ''));
let senderId = $state(untrack(() => data.filters?.senderId || ''));
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
// Debounce Timer
let searchTimer: any;
let searchTimer: ReturnType<typeof setTimeout>;
let showAdvanced = false;
const hasAdvancedFilters = (filters: typeof data.filters) =>
(filters?.tags?.length ?? 0) > 0 ||
!!filters?.senderId ||
!!filters?.receiverId ||
!!filters?.from ||
!!filters?.to;
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
function triggerSearch() {
const params = new URLSearchParams();
const params = new SvelteURLSearchParams();
if (q) params.set('q', q);
if (from) params.set('from', from);
@@ -42,27 +52,27 @@ function handleTextSearch() {
}, 500);
}
let previousTags = tagNames.join(',');
$: {
const currentTags = tagNames.join(',');
if (currentTags !== previousTags) {
previousTags = currentTags;
// Trigger search when tags change
let prevTagStr = untrack(() => tagNames.join(','));
$effect(() => {
const cur = tagNames.join(',');
if (cur !== prevTagStr) {
prevTagStr = cur;
triggerSearch();
}
}
});
function toggleAdvanced() {
showAdvanced = !showAdvanced;
}
// Sync with server data (e.g. after reset)
$: {
q = data.filters?.q || '';
// Sync local state with server data after navigation.
// Guard q: skip overwrite while the user is actively typing in the search field.
$effect(() => {
if (!qFocused) q = data.filters?.q || '';
from = data.filters?.from || '';
to = data.filters?.to || '';
senderId = data.filters?.senderId || '';
receiverId = data.filters?.receiverId || '';
}
tagNames = data.filters?.tags || [];
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
});
</script>
<!-- Outer Container: Matches the 'Sand' background of the layout -->
@@ -76,59 +86,48 @@ $: {
<input
type="text"
bind:value={q}
on:input={handleTextSearch}
placeholder="Suche in Titel, Inhalt, Ort..."
oninput={handleTextSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
placeholder={m.docs_search_placeholder()}
class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/></svg
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-40"
/>
</div>
</div>
<!-- Toggle Advanced Button -->
<button
on:click={toggleAdvanced}
onclick={() => (showAdvanced = !showAdvanced)}
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
>
<svg
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
Filter
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
/>
{m.docs_btn_filter()}
</button>
<!-- Reset Button -->
<a
href="/"
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-gray-400 transition hover:text-red-500"
title="Filter zurücksetzen"
title={m.docs_btn_reset_title()}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 opacity-40"
/>
</a>
</div>
@@ -141,9 +140,9 @@ $: {
<!-- Tag Filter -->
<div class="md:col-span-12">
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
Schlagworte
{m.docs_filter_label_tags()}
</p>
<TagInput bind:tags={tagNames} allowCreation={false} on:change={triggerSearch} />
<TagInput bind:tags={tagNames} allowCreation={false} />
</div>
<!-- Sender -->
@@ -153,10 +152,10 @@ $: {
>
<PersonTypeahead
name="senderId"
label="Absender"
label={m.docs_filter_label_sender()}
bind:value={senderId}
initialName={data.initialValues?.senderName}
on:change={triggerSearch}
onchange={triggerSearch}
/>
</div>
</div>
@@ -168,10 +167,10 @@ $: {
>
<PersonTypeahead
name="receiverId"
label="Empfänger"
label={m.docs_filter_label_receivers()}
bind:value={receiverId}
initialName={data.initialValues?.receiverName}
on:change={triggerSearch}
onchange={triggerSearch}
/>
</div>
</div>
@@ -182,13 +181,13 @@ $: {
<label
for="from"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>Von</label
>{m.docs_filter_label_from()}</label
>
<input
type="date"
id="from"
bind:value={from}
on:change={triggerSearch}
onchange={triggerSearch}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
/>
</div>
@@ -196,13 +195,13 @@ $: {
<label
for="to"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>Bis</label
>{m.docs_filter_label_to()}</label
>
<input
type="date"
id="to"
bind:value={to}
on:change={triggerSearch}
onchange={triggerSearch}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
/>
</div>
@@ -213,15 +212,20 @@ $: {
<!-- DOCUMENT LIST HEADER -->
<div class="mb-2 flex justify-end">
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neues Dokument
</a>
{#if data.canWrite}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.docs_btn_new()}
</a>
{/if}
</div>
<!-- DOCUMENT LIST -->
@@ -232,7 +236,7 @@ $: {
</div>
{:else if data.documents && data.documents.length > 0}
<ul class="divide-y divide-gray-100">
{#each data.documents as doc}
{#each data.documents as doc (doc.id)}
<li class="group transition-colors duration-200 hover:bg-brand-sand/10">
<!-- LINK TO DETAIL PAGE -->
<a href="/documents/{doc.id}" class="block p-6">
@@ -261,39 +265,22 @@ $: {
<!-- Metadata Row -->
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-gray-500">
<div class="flex items-center">
<svg
class="mr-1.5 h-4 w-4 text-brand-mint"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/></svg
>
{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : '—'}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
{#if doc.location}
<div class="flex items-center">
<svg
class="mr-1.5 h-4 w-4 text-brand-mint"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/></svg
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.location}
</div>
{/if}
@@ -304,40 +291,39 @@ $: {
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
>Von</span
>{m.docs_list_from()}</span
>
{#if doc.sender}
<span class="text-gray-900"
>{doc.sender.firstName} {doc.sender.lastName}</span
>
{:else}
<span class="text-gray-400 italic">Unbekannt</span>
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
>An</span
>{m.docs_list_to()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-gray-900">
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
</span>
{:else}
<span class="text-gray-400 italic">Unbekannt</span>
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
</div>
<!-- NEW: Tags Display -->
<!-- Tags Display -->
{#if doc.tags && doc.tags.length > 0}
<div class="mt-4 flex flex-wrap gap-2 pt-3">
{#each doc.tags as tag}
{#each doc.tags as tag (tag.id)}
<button
type="button"
class="relative z-10 inline-flex items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
on:click|preventDefault|stopPropagation={() =>
goto(`/?tag=${encodeURIComponent(tag.name)}`)}
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
>
{tag.name}
</button>
@@ -350,14 +336,12 @@ $: {
<div
class="hidden items-center text-gray-300 transition-colors group-hover:text-brand-mint sm:flex"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
</div>
</a>
@@ -370,24 +354,22 @@ $: {
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30"
>
<svg class="h-6 w-6 text-brand-navy" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/></svg
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
<h3 class="font-serif text-lg font-medium text-brand-navy">Keine Dokumente gefunden</h3>
<h3 class="font-serif text-lg font-medium text-brand-navy">{m.docs_empty_heading()}</h3>
<p class="mt-1 font-sans text-sm text-gray-500">
Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.
{m.docs_empty_text()}
</p>
<button
on:click={() => goto('/')}
onclick={() => goto('/')}
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
>
Alle Filter löschen
{m.docs_empty_btn_clear()}
</button>
</div>
{/if}

View File

@@ -2,146 +2,130 @@ import { error, fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
type ApiResult = { response: Response; error?: unknown };
function toActionResult(result: ApiResult) {
if (!result.response.ok) {
const code = (result.error as { code?: string } | undefined)?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
}
export async function load({ fetch, locals }) {
const user = locals.user;
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'));
if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN'));
const user = locals.user;
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('ADMIN')
);
if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN'));
const api = createApiClient(fetch);
const api = createApiClient(fetch);
const [usersResult, groupsResult, tagsResult] = await Promise.all([
api.GET('/api/users'),
api.GET('/api/groups'),
api.GET('/api/tags')
]);
const [usersResult, groupsResult, tagsResult] = await Promise.all([
api.GET('/api/users'),
api.GET('/api/groups'),
api.GET('/api/tags')
]);
return {
users: usersResult.data ?? [],
groups: groupsResult.data ?? [],
tags: tagsResult.data ?? []
};
return {
users: usersResult.data ?? [],
groups: groupsResult.data ?? [],
tags: tagsResult.data ?? []
};
}
export const actions = {
createUser: async ({ request, fetch }) => {
const data = await request.formData();
const api = createApiClient(fetch);
createUser: async ({ request, fetch }) => {
const data = await request.formData();
const api = createApiClient(fetch);
const result = await api.POST('/api/users', {
body: {
username: data.get('username') as string,
initialPassword: data.get('password') as string,
groupIds: data.getAll('groupIds') as string[]
}
});
const result = await api.POST('/api/users', {
body: {
username: data.get('username') as string,
initialPassword: data.get('password') as string,
groupIds: data.getAll('groupIds') as string[]
}
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
},
return toActionResult(result);
},
deleteUser: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
deleteUser: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
const result = await api.DELETE('/api/users/{id}', {
params: { path: { id } }
});
const result = await api.DELETE('/api/users/{id}', {
params: { path: { id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
},
return toActionResult(result);
},
updateTag: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
updateTag: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
const result = await api.PUT('/api/tags/{id}', {
params: { path: { id } },
body: { name: data.get('name') as string }
});
const result = await api.PUT('/api/tags/{id}', {
params: { path: { id } },
body: { name: data.get('name') as string }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
},
return toActionResult(result);
},
deleteTag: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
deleteTag: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
const result = await api.DELETE('/api/tags/{id}', {
params: { path: { id } }
});
const result = await api.DELETE('/api/tags/{id}', {
params: { path: { id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
},
return toActionResult(result);
},
createGroup: async ({ request, fetch }) => {
const data = await request.formData();
const api = createApiClient(fetch);
createGroup: async ({ request, fetch }) => {
const data = await request.formData();
const api = createApiClient(fetch);
const result = await api.POST('/api/groups', {
body: {
name: data.get('name') as string,
permissions: data.getAll('permissions') as string[]
}
});
const result = await api.POST('/api/groups', {
body: {
name: data.get('name') as string,
permissions: data.getAll('permissions') as string[]
}
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
},
return toActionResult(result);
},
updateGroup: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
updateGroup: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
const result = await api.PATCH('/api/groups/{id}', {
params: { path: { id } },
body: {
name: data.get('name') as string,
permissions: data.getAll('permissions') as string[]
}
});
const result = await api.PATCH('/api/groups/{id}', {
params: { path: { id } },
body: {
name: data.get('name') as string,
permissions: data.getAll('permissions') as string[]
}
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
},
return toActionResult(result);
},
deleteGroup: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
deleteGroup: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id') as string;
const api = createApiClient(fetch);
const result = await api.DELETE('/api/groups/{id}', {
params: { path: { id } }
});
const result = await api.DELETE('/api/groups/{id}', {
params: { path: { id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
}
return toActionResult(result);
}
};

View File

@@ -1,112 +1,111 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { slide } from 'svelte/transition';
import { enhance } from '$app/forms';
import { slide } from 'svelte/transition';
import { m } from '$lib/paraglide/messages.js';
export let data;
export let form;
let { data, form } = $props();
let activeTab = 'users';
let editingTagId: string | null = null;
let editingTagName = '';
let editingUserId: string | null = null;
let activeTab = $state('users');
let editingTagId: string | null = $state(null);
let editingTagName = $state('');
let editingUserId: string | null = $state(null);
let editingGroupId: string | null = $state(null);
function startEditTag(tag: any) {
editingTagId = tag.id;
editingTagName = tag.name;
}
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
function cancelEditTag() {
editingTagId = null;
editingTagName = '';
}
function startEditTag(tag: { id: string; name: string }) {
editingTagId = tag.id;
editingTagName = tag.name;
}
function startEditUser(id: string) {
editingUserId = id;
}
function cancelEditTag() {
editingTagId = null;
editingTagName = '';
}
function cancelEditUser() {
editingUserId = null;
}
function startEditUser(id: string) {
editingUserId = id;
}
let editingGroupId: string | null = null;
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
function cancelEditUser() {
editingUserId = null;
}
function startEditGroup(id: string) {
editingGroupId = id;
}
function startEditGroup(id: string) {
editingGroupId = id;
}
function cancelEditGroup() {
editingGroupId = null;
}
function cancelEditGroup() {
editingGroupId = null;
}
</script>
<div class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8 font-sans">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-serif text-brand-navy">Admin Dashboard</h1>
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
<div class="mb-8 flex items-center justify-between">
<h1 class="font-serif text-3xl text-brand-navy">{m.admin_heading()}</h1>
<!-- Tabs -->
<div class="flex bg-white rounded-lg p-1 shadow-sm border border-gray-200">
<div class="flex rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
'users'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'users')}>Benutzer</button
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
'groups'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'groups')}>Gruppen</button
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
'tags'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'tags')}>Schlagworte</button
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
>
</div>
</div>
{#if form?.message}
<div class="bg-brand-mint/20 text-brand-navy p-4 rounded mb-6 border border-brand-mint/50">
<div class="mb-6 rounded border border-brand-mint/50 bg-brand-mint/20 p-4 text-brand-navy">
{form.message}
</div>
{/if}
{#if activeTab === 'users'}
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 class="text-lg font-bold text-gray-700">Benutzerverwaltung</h2>
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
<div class="flex items-center justify-between border-b border-gray-100 p-6">
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Login</th
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_login()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Gruppen</th
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_groups()}</th
>
{#if editingUserId}
<th
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
>Passwort</th
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_password()}</th
>
{/if}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.users as user}
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.users as user (user.id)}
<tr class="group/row hover:bg-gray-50">
{#if editingUserId === user.id}
<!-- === EDIT MODE === -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{user.username}
<!-- Hidden ID Input for the form -->
<input
type="hidden"
name="username"
@@ -116,27 +115,25 @@
</td>
<td class="px-6 py-4 text-sm">
<!-- Groups Select -->
<select
name="groupIds"
multiple
form="edit-form-{user.id}"
class="block w-full rounded border-brand-mint text-xs p-1 min-h-[80px]"
class="block min-h-[80px] w-full rounded border-brand-mint p-1 text-xs"
>
{#each data.groups as group}
{#each data.groups as group (group.id)}
<option
value={group.id}
selected={user.groups.some((g) => g.id === group.id)}
selected={user.groups.some((g: { id: string }) => g.id === group.id)}
>
{group.name}
</option>
{/each}
</select>
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Auswahl</p>
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint()}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
<!-- Password & Buttons -->
<td class="px-6 py-4 text-right align-top whitespace-nowrap">
<form
id="edit-form-{user.id}"
method="POST"
@@ -151,63 +148,61 @@
<input
type="password"
name="password"
placeholder="Neues PW (optional)"
class="w-32 py-1 px-2 text-xs border border-brand-mint rounded"
placeholder={m.admin_password_placeholder()}
class="w-32 rounded border border-brand-mint px-2 py-1 text-xs"
/>
<div class="flex gap-2 mt-1">
<div class="mt-1 flex gap-2">
<button
type="submit"
class="bg-green-600 text-white px-2 py-1 rounded text-xs font-bold uppercase hover:bg-green-700"
class="rounded bg-green-600 px-2 py-1 text-xs font-bold text-white uppercase hover:bg-green-700"
>
Speichern
{m.btn_save()}
</button>
<button
type="button"
on:click={cancelEditUser}
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
onclick={cancelEditUser}
class="rounded bg-gray-200 px-2 py-1 text-xs font-bold text-gray-600 uppercase hover:bg-gray-300"
>
Abbrechen
{m.btn_cancel()}
</button>
</div>
</form>
</td>
{:else}
<!-- === VIEW MODE === -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{user.username}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#if user.groups && user.groups.length > 0}
{#each user.groups as group}
{#each user.groups as group (group.id)}
<span
class="px-2 py-0.5 text-[10px] font-bold uppercase rounded-full bg-blue-50 text-blue-700 border border-blue-100"
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
>
{group.name}
</span>
{/each}
{:else}
<span class="text-xs text-gray-400 italic">Keine Gruppen</span>
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
{/if}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-4">
<!-- Edit Button -->
<button
on:click={() => startEditUser(user.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
onclick={() => startEditUser(user.id)}
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
>
Bearbeiten
{m.btn_edit()}
</button>
<!-- Delete Button -->
<form
method="POST"
action="?/deleteUser"
use:enhance={({ cancel }) => {
if (!confirm(`Benutzer '${user.username}' wirklich löschen?`)) {
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
cancel();
}
return async ({ update }) => {
@@ -218,10 +213,10 @@
>
<input type="hidden" name="id" value={user.id} />
<button
class="text-gray-300 hover:text-red-600 transition-colors p-1"
title="Benutzer löschen"
class="p-1 text-gray-300 transition-colors hover:text-red-600"
title={m.admin_btn_delete_user_title()}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -240,68 +235,66 @@
</table>
<!-- Create User Form -->
<div class="p-6 bg-gray-50 border-t border-gray-200">
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
Neuen Benutzer anlegen
<div class="border-t border-gray-200 bg-gray-50 p-6">
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
{m.admin_section_new_user()}
</h3>
<form
method="POST"
action="?/createUser"
use:enhance
class="grid grid-cols-1 md:grid-cols-6 gap-4 items-start"
class="grid grid-cols-1 items-start gap-4 md:grid-cols-6"
>
<input
type="text"
name="username"
placeholder="Login"
required
class="rounded border-gray-300 text-sm w-full"
class="w-full rounded border-gray-300 text-sm"
/>
<input
type="password"
name="password"
placeholder="Passwort"
placeholder={m.admin_col_password()}
required
class="rounded border-gray-300 text-sm w-full"
class="w-full rounded border-gray-300 text-sm"
/>
<!-- Multi-Select for Groups -->
<div class="md:col-span-3">
<select
name="groupIds"
multiple
class="rounded border-gray-300 text-sm w-full h-[42px] py-1"
class="h-[42px] w-full rounded border-gray-300 py-1 text-sm"
required
title="Strg+Klick für mehrere"
title={m.admin_multiselect_hint_multi()}
>
{#each data.groups as group}
{#each data.groups as group (group.id)}
<option value={group.id}>{group.name}</option>
{/each}
</select>
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Mehrfachauswahl</p>
<p class="mt-1 text-[10px] text-gray-400">{m.admin_multiselect_hint_full()}</p>
</div>
<button
type="submit"
class="bg-brand-navy text-white h-[42px] rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full"
>Anlegen</button
class="h-[42px] w-full rounded bg-brand-navy text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy"
>{m.btn_create()}</button
>
</form>
</div>
</div>
{:else if activeTab === 'tags'}
<!-- TAGS SECTION (unchanged logic, just ensuring style consistency) -->
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 bg-yellow-50/50">
<h2 class="text-lg font-bold text-gray-700">Schlagworte</h2>
<p class="text-xs text-yellow-800 mt-1">
Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
<div class="border-b border-gray-100 bg-yellow-50/50 p-6">
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_tags()}</h2>
<p class="mt-1 text-xs text-yellow-800">
{m.admin_tags_warning()}
</p>
</div>
<ul class="divide-y divide-gray-100 max-h-[600px] overflow-y-auto">
{#each data.tags as tag}
<li class="px-6 py-3 flex items-center justify-between hover:bg-gray-50 group">
<ul class="max-h-[600px] divide-y divide-gray-100 overflow-y-auto">
{#each data.tags as tag (tag.id)}
<li class="group flex items-center justify-between px-6 py-3 hover:bg-gray-50">
{#if editingTagId === tag.id}
<form
method="POST"
@@ -311,17 +304,17 @@
await update();
cancelEditTag();
}}
class="flex-1 flex gap-2 items-center"
class="flex flex-1 items-center gap-2"
>
<input type="hidden" name="id" value={tag.id} />
<input
type="text"
name="name"
bind:value={editingTagName}
class="flex-1 border-brand-mint ring-1 ring-brand-mint rounded px-2 py-1 text-sm"
class="flex-1 rounded border-brand-mint px-2 py-1 text-sm ring-1 ring-brand-mint"
/>
<button aria-label="Speichern" class="text-green-600 hover:text-green-800"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
@@ -332,10 +325,10 @@
>
<button
type="button"
on:click={cancelEditTag}
aria-label="Abbrechen"
class="text-gray-400 hover:text-gray-600"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
onclick={cancelEditTag}
aria-label={m.btn_cancel()}
class="text-gray-400 hover:text-gray-600"
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
@@ -346,18 +339,18 @@
>
</form>
{:else}
<span class="text-sm font-medium text-brand-navy bg-brand-sand/30 px-2 py-1 rounded">
<span class="rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy">
{tag.name}
</span>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
on:click={() => startEditTag(tag)}
aria-label="Schlagwort bearbeiten"
onclick={() => startEditTag(tag)}
aria-label={m.admin_btn_edit_tag_label()}
class="p-1 text-gray-400 hover:text-brand-navy"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
@@ -370,16 +363,11 @@
method="POST"
action="?/deleteTag"
use:enhance={({ cancel }) => {
// This runs BEFORE the request is sent
if (
!confirm(
'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.'
)
!confirm(m.admin_tag_delete_confirm())
) {
cancel(); // Stop the request
cancel();
}
// This runs AFTER the server responds
return async ({ update }) => {
await update();
};
@@ -387,8 +375,11 @@
class="inline"
>
<input type="hidden" name="id" value={tag.id} />
<button aria-label="Schlagwort löschen" class="p-1 text-gray-400 hover:text-red-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
<button
aria-label={m.admin_btn_delete_tag_label()}
class="p-1 text-gray-400 hover:text-red-600"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
@@ -405,28 +396,28 @@
</ul>
</div>
{:else if activeTab === 'groups'}
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 class="text-lg font-bold text-gray-700">Gruppenverwaltung</h2>
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
<div class="flex items-center justify-between border-b border-gray-100 p-6">
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_groups()}</h2>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Name</th
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_name()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Berechtigungen</th
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_permissions()}</th
>
<th
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
>Aktionen</th
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_actions()}</th
>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.groups as group}
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.groups as group (group.id)}
<tr class="group/row hover:bg-gray-50">
{#if editingGroupId === group.id}
<!-- EDIT MODE -->
@@ -439,24 +430,22 @@
await update();
cancelEditGroup();
}}
class="flex flex-col sm:flex-row items-start gap-4 w-full"
class="flex w-full flex-col items-start gap-4 sm:flex-row"
>
<input type="hidden" name="id" value={group.id} />
<!-- Name Input -->
<div class="w-full sm:w-1/3">
<input
type="text"
name="name"
value={group.name}
class="w-full text-sm border-brand-mint rounded"
class="w-full rounded border-brand-mint text-sm"
required
/>
</div>
<!-- Permissions Checkboxes -->
<div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2">
{#each availablePermissions as perm}
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
{#each availablePermissions as perm (perm)}
<label
class="inline-flex items-center text-xs font-bold text-gray-600 uppercase"
>
@@ -465,17 +454,20 @@
name="permissions"
value={perm}
checked={group.permissions.includes(perm)}
class="mr-2 text-brand-navy focus:ring-brand-mint rounded border-gray-300"
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
/>
{perm.replace('_', ' ')}
</label>
{/each}
</div>
<!-- Actions -->
<div class="flex gap-2 self-start sm:self-center">
<button type="submit" aria-label="Speichern" class="text-green-600 hover:text-green-800 p-1">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
<button
type="submit"
aria-label={m.btn_save()}
class="p-1 text-green-600 hover:text-green-800"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
@@ -486,11 +478,11 @@
</button>
<button
type="button"
on:click={cancelEditGroup}
aria-label="Abbrechen"
class="text-gray-400 hover:text-red-500 p-1"
onclick={cancelEditGroup}
aria-label={m.btn_cancel()}
class="p-1 text-gray-400 hover:text-red-500"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
@@ -504,40 +496,39 @@
</td>
{:else}
<!-- VIEW MODE -->
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-brand-navy">
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-brand-navy">
{group.name}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#each group.permissions as perm}
{#each group.permissions as perm (perm)}
<span
class="px-2 py-0.5 text-[10px] font-bold uppercase rounded-full
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
{perm === 'ADMIN'
? 'bg-red-50 text-red-700 border-red-100'
: 'bg-gray-100 text-gray-600 border-gray-200'}"
? 'border-red-100 bg-red-50 text-red-700'
: 'border-gray-200 bg-gray-100 text-gray-600'}"
>
{perm}
</span>
{/each}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-3">
<button
on:click={() => startEditGroup(group.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
onclick={() => startEditGroup(group.id)}
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
>
Bearbeiten
{m.btn_edit()}
</button>
<form
method="POST"
action="?/deleteGroup"
use:enhance={({ cancel }) => {
if (!confirm('Gruppe wirklich löschen?')) {
if (!confirm(m.admin_group_delete_confirm())) {
cancel();
}
return async ({ update }) => {
await update();
};
@@ -545,10 +536,10 @@
>
<input type="hidden" name="id" value={group.id} />
<button
class="text-gray-300 hover:text-red-600 p-1 transition-colors"
title="Löschen"
class="p-1 text-gray-300 transition-colors hover:text-red-600"
title={m.btn_delete()}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -567,34 +558,34 @@
</table>
<!-- CREATE GROUP FORM -->
<div class="p-6 bg-gray-50 border-t border-gray-200">
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
Neue Gruppe anlegen
<div class="border-t border-gray-200 bg-gray-50 p-6">
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
{m.admin_section_new_group()}
</h3>
<form
method="POST"
action="?/createGroup"
use:enhance
class="flex flex-col md:flex-row gap-4 items-start md:items-center"
class="flex flex-col items-start gap-4 md:flex-row md:items-center"
>
<div class="flex-1 w-full">
<div class="w-full flex-1">
<input
type="text"
name="name"
placeholder="Gruppenname (z.B. Editoren)"
placeholder={m.admin_group_name_placeholder()}
required
class="rounded border-gray-300 text-sm w-full"
class="w-full rounded border-gray-300 text-sm"
/>
</div>
<div class="flex gap-4 items-center">
{#each availablePermissions as perm}
<div class="flex items-center gap-4">
{#each availablePermissions as perm (perm)}
<label class="inline-flex items-center text-xs font-bold text-gray-600 uppercase">
<input
type="checkbox"
name="permissions"
value={perm}
class="mr-2 text-brand-navy focus:ring-brand-mint rounded border-gray-300"
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
/>
{perm.replace('_', ' ')}
</label>
@@ -603,9 +594,9 @@
<button
type="submit"
class="bg-brand-navy text-white px-6 py-2 rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full md:w-auto"
class="w-full rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy md:w-auto"
>
Anlegen
{m.btn_create()}
</button>
</form>
</div>

View File

@@ -0,0 +1,19 @@
import type { RequestHandler } from './$types';
import { env } from 'process';
export const GET: RequestHandler = async ({ params, fetch }) => {
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/documents/${params.id}/file`;
const response = await fetch(backendUrl);
if (!response.ok) {
return new Response(null, { status: response.status });
}
return new Response(response.body, {
headers: {
'Content-Type': response.headers.get('Content-Type') ?? 'application/octet-stream',
'Content-Disposition': response.headers.get('Content-Disposition') ?? ''
}
});
};

View File

@@ -1,34 +1,34 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from 'process';
export const GET: RequestHandler = async ({ url, fetch }) => {
// 1. Suchparameter aus der URL des Browsers holen
const q = url.searchParams.get('q') || '';
// 1. Suchparameter aus der URL des Browsers holen
const q = url.searchParams.get('q') || '';
try {
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
const backendUrl = `http://localhost:8080/api/persons?q=${encodeURIComponent(q)}`;
try {
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/persons?q=${encodeURIComponent(q)}`;
const response = await fetch(backendUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
const response = await fetch(backendUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.error(`Backend Error: ${response.status}`);
return json([], { status: response.status });
}
if (!response.ok) {
console.error(`Backend Error: ${response.status}`);
return json([], { status: response.status });
}
const data = await response.json();
const data = await response.json();
// 4. Daten zurück an den Browser schicken
return json(data);
} catch (error) {
console.error("Proxy Error:", error);
return json([], { status: 500 });
}
// 4. Daten zurück an den Browser schicken
return json(data);
} catch (error) {
console.error('Proxy Error:', error);
return json([], { status: 500 });
}
};

View File

@@ -1,35 +1,35 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from 'process';
export const GET: RequestHandler = async ({ url, fetch }) => {
// 1. Suchparameter aus der URL des Browsers holen
const q = url.searchParams.get('q') || '';
// 1. Suchparameter aus der URL des Browsers holen
const q = url.searchParams.get('q') || '';
try {
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
const backendUrl = `http://localhost:8080/api/tags?q=${encodeURIComponent(q)}`;
try {
// 3. Anfrage an das Java-Backend weiterleiten (Server-to-Server)
// Wir nutzen hier den internen Docker-Hostnamen oder localhost, je nach Netzwerk
const backendUrl = `${env.API_INTERNAL_URL || 'http://localhost:8080'}/api/tags?q=${encodeURIComponent(q)}`;
const response = await fetch(backendUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
const response = await fetch(backendUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.error(`Backend Error: ${response.status}`);
return json([], { status: response.status });
}
if (!response.ok) {
console.error(`Backend Error: ${response.status}`);
return json([], { status: response.status });
}
const data = await response.json();
console.log("Tags Data", data)
const data = await response.json();
console.log('Tags Data', data);
// 4. Daten zurück an den Browser schicken
return json(data);
} catch (error) {
console.error("Proxy Error:", error);
return json([], { status: 500 });
}
// 4. Daten zurück an den Browser schicken
return json(data);
} catch (error) {
console.error('Proxy Error:', error);
return json([], { status: 500 });
}
};

View File

@@ -2,61 +2,63 @@ import type { components } from '$lib/generated/api';
import { createApiClient } from '$lib/api.server';
export async function load({ url, fetch }) {
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || '';
const dir = url.searchParams.get('dir') || 'DESC';
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || '';
const dir = url.searchParams.get('dir') || 'DESC';
const api = createApiClient(fetch);
const api = createApiClient(fetch);
let documents: components['schemas']['Document'][] = [];
let senderName = '';
let receiverName = '';
let documents: components['schemas']['Document'][] = [];
let senderName = '';
let receiverName = '';
const requests: Promise<void>[] = [];
const requests: Promise<void>[] = [];
if (senderId && receiverId) {
requests.push(
api.GET('/api/documents/conversation', {
params: {
query: {
senderId,
receiverId,
dir,
from: from || undefined,
to: to || undefined
}
}
}).then(({ data }) => { documents = data ?? []; })
);
}
if (senderId && receiverId) {
requests.push(
api
.GET('/api/documents/conversation', {
params: {
query: {
senderId,
receiverId,
dir,
from: from || undefined,
to: to || undefined
}
}
})
.then(({ data }) => {
documents = data ?? [];
})
);
}
if (senderId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } })
.then(({ data }) => {
const p = data as { firstName: string; lastName: string } | undefined;
if (p) senderName = `${p.firstName} ${p.lastName}`;
})
);
}
if (senderId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
const p = data as { firstName: string; lastName: string } | undefined;
if (p) senderName = `${p.firstName} ${p.lastName}`;
})
);
}
if (receiverId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } })
.then(({ data }) => {
const p = data as { firstName: string; lastName: string } | undefined;
if (p) receiverName = `${p.firstName} ${p.lastName}`;
})
);
}
if (receiverId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
const p = data as { firstName: string; lastName: string } | undefined;
if (p) receiverName = `${p.firstName} ${p.lastName}`;
})
);
}
await Promise.all(requests);
await Promise.all(requests);
return {
documents,
initialValues: { senderName, receiverName },
filters: { senderId, receiverId, from, to, dir }
};
return {
documents,
initialValues: { senderName, receiverName },
filters: { senderId, receiverId, from, to, dir }
};
}

View File

@@ -1,113 +1,155 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { goto } from '$app/navigation';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
export let data;
let { data } = $props();
// Data & State
let documents: typeof data.documents = [];
let initialValues = { senderName: '', receiverName: '' };
let senderId = $state(untrack(() => data.filters.senderId));
let receiverId = $state(untrack(() => data.filters.receiverId));
let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
// Filter State
let senderId = '';
let receiverId = '';
let fromDate = '';
let toDate = '';
let sortDir = 'DESC';
const documentYears = $derived(
data.documents
.map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null))
.filter((y): y is number => y !== null)
);
const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
// Reactive Update
$: {
documents = data.documents;
initialValues = data.initialValues;
senderId = data.filters.senderId;
receiverId = data.filters.receiverId;
fromDate = data.filters.from;
toDate = data.filters.to;
sortDir = data.filters.dir;
}
// Sync with server data after navigation
$effect(() => {
senderId = data.filters.senderId;
receiverId = data.filters.receiverId;
fromDate = data.filters.from;
toDate = data.filters.to;
sortDir = data.filters.dir;
});
// Filter Logic
function applyFilters() {
setTimeout(() => {
const params = new URLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`?${params.toString()}`, { keepFocus: true });
}, 0);
}
function applyFilters() {
const params = new SvelteURLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`/conversations?${params.toString()}`, { keepFocus: true });
}
function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
function handleSenderChange(event: CustomEvent) {
senderId = event.detail.value;
applyFilters();
}
function swapPersons() {
const tmp = senderId;
senderId = receiverId;
receiverId = tmp;
applyFilters();
}
function handleReceiverChange(event: CustomEvent) {
receiverId = event.detail.value;
applyFilters();
}
const enrichedDocuments = $derived(
data.documents.map((doc, i) => {
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
const prevYear =
i > 0 && data.documents[i - 1].documentDate
? new Date(data.documents[i - 1].documentDate!).getFullYear()
: null;
return { doc, year, showYearDivider: year !== null && year !== prevYear };
})
);
</script>
<div class="max-w-5xl mx-auto py-10 px-4">
<div class="mx-auto max-w-5xl px-4 py-10">
<!-- Page Header -->
<div class="mb-8 border-b border-brand-navy/10 pb-4">
<h1 class="text-3xl font-serif font-medium text-brand-navy">Konversationen</h1>
<p class="text-brand-navy/60 font-sans text-sm mt-2">
Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.
<h1 class="font-serif text-3xl font-medium text-brand-navy">{m.conv_heading()}</h1>
<p class="mt-2 font-sans text-sm text-brand-navy/60">
{m.conv_subtitle()}
</p>
</div>
<!-- FILTER BAR -->
<div class="bg-white p-8 shadow-sm border border-brand-sand mb-10 relative z-20">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
<div class="relative z-20 mb-10 border border-brand-sand bg-white p-8 shadow-sm">
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label="Person A (Absender)"
value={senderId}
initialName={initialValues.senderName}
on:change={handleSenderChange}
label={m.conv_label_person_a()}
bind:value={senderId}
initialName={data.initialValues.senderName}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => applyFilters()}
/>
</div>
<!-- Swap button — always rendered to hold grid column width on desktop.
On mobile: hidden (display:none) when no persons selected so no gap appears.
On desktop: invisible (visibility:hidden) when no persons so both 1fr columns stay equal. -->
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
<button
data-testid="conv-swap-btn"
onclick={swapPersons}
class="flex w-full items-center justify-center gap-2 border border-brand-sand px-3 py-2.5 text-xs font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white md:w-auto {senderId &&
receiverId
? ''
: 'invisible'}"
title={m.conv_swap_btn()}
>
<svg
class="h-4 w-4 flex-shrink-0 md:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
></path>
</svg>
<span class="md:hidden">{m.conv_swap_btn()}</span>
</button>
</div>
<!-- Receiver -->
<div
class="relative z-30 [&_label]:text-xs [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:mb-2 [&_input]:py-2.5 [&_input]:border-gray-300 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy"
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label="Person B (Empfänger)"
value={receiverId}
initialName={initialValues.receiverName}
on:change={handleReceiverChange}
label={m.conv_label_person_b()}
bind:value={receiverId}
initialName={data.initialValues.receiverName}
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => applyFilters()}
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-end relative z-10">
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
>Zeitraum von</label
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>{m.conv_label_from()}</label
>
<input
id="dateFrom"
type="date"
bind:value={fromDate}
on:change={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
onchange={() => applyFilters()}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
@@ -115,28 +157,28 @@
<div>
<label
for="dateTo"
class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2"
>Zeitraum bis</label
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>{m.conv_label_to()}</label
>
<input
id="dateTo"
type="date"
bind:value={toDate}
on:change={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
onchange={() => applyFilters()}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<!-- Sort Toggle -->
<div>
<button
on:click={toggleSort}
class="w-full flex items-center justify-center h-[42px] border border-brand-sand text-xs font-bold uppercase tracking-wide text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
onclick={toggleSort}
class="flex h-[42px] w-full items-center justify-center border border-brand-sand text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
>
<span class="mr-2">Sortierung:</span>
<span>{sortDir === 'DESC' ? 'Neueste zuerst' : 'Älteste zuerst'}</span>
<span class="mr-2">{m.conv_sort_label()}</span>
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
<svg
class="w-4 h-4 ml-2 transform {sortDir === 'ASC'
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
@@ -154,10 +196,10 @@
<!-- RESULTS LIST SECTION -->
{#if !senderId || !receiverId}
<div
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand border-dashed rounded-sm text-center"
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-brand-sand bg-white py-24 text-center"
>
<div class="bg-brand-sand/30 p-4 rounded-full mb-4 text-brand-navy">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
<div class="mb-4 rounded-full bg-brand-sand/30 p-4 text-brand-navy">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
@@ -166,47 +208,80 @@
/></svg
>
</div>
<p class="text-brand-navy font-serif text-lg">Wählen Sie zwei Personen aus</p>
<p class="text-gray-500 font-sans text-sm mt-1">Die Korrespondenz wird hier angezeigt.</p>
<p class="font-serif text-lg text-brand-navy">{m.conv_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-gray-500">{m.conv_empty_text()}</p>
</div>
{:else if documents.length === 0}
{:else if data.documents.length === 0}
<div
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm"
class="flex flex-col items-center justify-center rounded-sm border border-brand-sand bg-white py-24 text-center shadow-sm"
>
<p class="text-brand-navy font-serif">Keine Dokumente gefunden.</p>
<p class="text-gray-400 text-sm mt-2">Versuchen Sie, den Zeitraum anzupassen.</p>
<p class="font-serif text-brand-navy">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-gray-400">{m.conv_no_results_text()}</p>
</div>
{:else}
<!-- Summary bar -->
<div class="mb-4 flex items-center justify-between">
{#if yearFrom !== null && yearTo !== null}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-brand-navy/70">
{m.conv_summary({ count: data.documents.length, yearFrom, yearTo })}
</p>
{:else}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-brand-navy/70">
{data.documents.length}
</p>
{/if}
{#if data.canWrite}
<a
data-testid="conv-new-doc-link"
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
></path>
</svg>
{m.conv_new_doc_link()}
</a>
{/if}
</div>
<!-- CHAT CONTAINER -->
<!-- Added: White background, Border, Shadow to separate from page -->
<div class="bg-white border border-brand-sand shadow-sm rounded-sm relative overflow-hidden">
<div class="relative overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
<!-- Decoration: Central Timeline Line -->
<div
class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block"
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-brand-sand/30 md:block"
></div>
<!-- Scrollable Area (optional, if you want max-height) -->
<div class="p-6 md:p-8">
<!-- TIGHTER GAP: Changed from gap-8 to gap-4 -->
<div class="flex flex-col gap-4 relative z-10">
{#each documents as doc}
<div class="relative z-10 flex flex-col gap-4">
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
{#if showYearDivider}
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
<div class="flex-grow border-t border-brand-sand"></div>
<span
class="mx-4 font-sans text-xs font-bold tracking-widest text-brand-navy/40 uppercase"
>{year}</span
>
<div class="flex-grow border-t border-brand-sand"></div>
</div>
{/if}
{@const isRight = doc.sender?.id === senderId}
<!-- Message Row -->
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
<!-- Bubble Group -->
<div
class="flex max-w-[90%] md:max-w-[70%] gap-3 {isRight
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR (Small) -->
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
<!-- AVATAR -->
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
<div
class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
{isRight
? 'bg-brand-navy text-white border-brand-navy'
: 'bg-white text-brand-navy border-brand-sand'}"
? 'border-brand-navy bg-brand-navy text-white'
: 'border-brand-sand bg-white text-brand-navy'}"
>
{#if doc.sender}
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
@@ -217,18 +292,17 @@
</div>
<!-- BUBBLE CARD -->
<!-- Adjusted padding (p-4) and added light bg to left bubbles for contrast -->
<a
href="/documents/{doc.id}"
class="group block p-4 rounded shadow-sm transition-all duration-200 transform hover:-translate-y-0.5 hover:shadow-md border
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
{isRight
? 'bg-brand-navy text-white border-brand-navy rounded-br-none'
: 'bg-brand-sand/10 text-brand-navy border-brand-sand rounded-bl-none'}"
? 'rounded-br-none border-brand-navy bg-brand-navy text-white'
: 'rounded-bl-none border-brand-sand bg-brand-sand/10 text-brand-navy'}"
>
<!-- Header -->
<div class="flex justify-between items-start gap-4 mb-2">
<div class="mb-2 flex items-start justify-between gap-4">
<h3
class="font-serif font-medium text-sm leading-snug {isRight
class="font-serif text-sm leading-snug font-medium {isRight
? 'text-white'
: 'text-brand-navy'}"
>
@@ -237,7 +311,7 @@
<!-- Status Dot -->
<span
class="flex-shrink-0 w-1.5 h-1.5 rounded-full mt-1.5
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
{doc.status === 'UPLOADED'
? 'bg-brand-mint'
: 'bg-yellow-400'}"
@@ -248,12 +322,12 @@
<!-- Metadata -->
<div
class="flex flex-wrap gap-3 text-[10px] font-sans uppercase tracking-wider opacity-80 {isRight
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
? 'text-blue-100'
: 'text-gray-500'}"
>
<span class="flex items-center">
{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : '—'}
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
{#if doc.location}
<span class="flex items-center">

View File

@@ -0,0 +1,163 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(cleanup);
// ─── Test data ────────────────────────────────────────────────────────────────
const baseData = {
user: undefined,
canWrite: true,
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
};
const withPersons = {
...baseData,
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
};
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED' as const,
documentDate: '1923-04-12',
location: 'Berlin',
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
tags: [],
transcription: undefined,
filePath: undefined,
createdAt: '1923-04-12T00:00:00Z',
updatedAt: '1923-04-12T00:00:00Z',
...overrides
});
const withDocs = {
...withPersons,
documents: [makeDoc()]
};
// ─── Empty state ──────────────────────────────────────────────────────────────
describe('Conversations page empty state', () => {
it('shows the "select two persons" prompt when no persons are selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
});
it('hides the swap button when no persons are selected', async () => {
render(Page, { data: baseData });
// Button is always in the DOM (holds grid column width on desktop) but made invisible
await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible');
});
it('does not show the new document link when no persons are selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
});
});
// ─── No results ───────────────────────────────────────────────────────────────
describe('Conversations page no results', () => {
it('shows "no documents found" when both persons are selected but there are no documents', async () => {
render(Page, { data: withPersons });
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
});
});
// ─── Swap button ──────────────────────────────────────────────────────────────
describe('Conversations page swap button', () => {
it('shows the swap button when both persons are selected', async () => {
render(Page, { data: withPersons });
await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
});
it('calls goto with swapped sender and receiver when clicked', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
render(Page, { data: withPersons });
document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]')!.click();
expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
});
});
// ─── Summary ──────────────────────────────────────────────────────────────────
describe('Conversations page summary', () => {
it('shows document count and year range when documents are loaded', async () => {
const data = {
...withPersons,
documents: [
makeDoc({ documentDate: '1923-04-12' }),
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
]
};
render(Page, { data });
const summary = page.getByTestId('conv-summary');
await expect.element(summary).toHaveTextContent('2');
await expect.element(summary).toHaveTextContent('1923');
await expect.element(summary).toHaveTextContent('1965');
});
});
// ─── Year dividers ────────────────────────────────────────────────────────────
describe('Conversations page year dividers', () => {
it('renders a year divider for the first document', async () => {
render(Page, { data: withDocs });
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
});
it('renders a divider for each new year in the document list', async () => {
const data = {
...withPersons,
documents: [
makeDoc({ documentDate: '1923-04-12' }),
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
]
};
render(Page, { data });
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965');
});
it('does not render a second divider for documents from the same year', async () => {
const data = {
...withPersons,
documents: [
makeDoc({ documentDate: '1923-04-12' }),
makeDoc({ id: 'd2', documentDate: '1923-09-01' })
]
};
render(Page, { data });
// Only one divider for 1923; 1965 divider should not appear
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument();
});
});
// ─── New document link ────────────────────────────────────────────────────────
describe('Conversations page new document link', () => {
it('shows the link with correct href for a write user', async () => {
render(Page, { data: { ...withDocs, canWrite: true } });
const link = page.getByTestId('conv-new-doc-link');
await expect.element(link).toBeInTheDocument();
await expect.element(link).toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2');
});
it('hides the link for a read-only user', async () => {
render(Page, { data: { ...withDocs, canWrite: false } });
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { resolve } from '$app/paths';
</script>
<a href={resolve('/demo/paraglide')}>paraglide</a>

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