Compare commits
93 Commits
cccc12429c
...
feat/38-do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca5726e7c3 | ||
|
|
0ef81e20f6 | ||
|
|
1ad8fffd1b | ||
|
|
5fb6a1eec0 | ||
|
|
4f69457a68 | ||
|
|
62f62a89a1 | ||
|
|
d84b997965 | ||
|
|
8c86beb9f9 | ||
|
|
0020d1e773 | ||
|
|
47b8cc9340 | ||
|
|
3e65b2feb3 | ||
|
|
f32ed32f67 | ||
|
|
4a0d3b3bea | ||
|
|
d4b1a709d7 | ||
|
|
7af49daf9c | ||
|
|
28256dbd08 | ||
|
|
315b368f88 | ||
|
|
43defa41c4 | ||
|
|
17db73d900 | ||
|
|
88e3fb32b3 | ||
|
|
c18cdbfac1 | ||
|
|
b9aff799fa | ||
|
|
908221f04d | ||
|
|
5f49a5787c | ||
|
|
6400cef390 | ||
|
|
f98792f10b | ||
|
|
70d858b65a | ||
|
|
c1e82a7edf | ||
|
|
7fbfeb3b39 | ||
|
|
bbac351f03 | ||
|
|
2411c330a2 | ||
|
|
7d095e159e | ||
|
|
ca73777010 | ||
|
|
0221382c8a | ||
|
|
ea6b727e44 | ||
|
|
2a46136f61 | ||
|
|
c0b9d979ea | ||
|
|
c84bb3ca7b | ||
|
|
cf8425d744 | ||
|
|
1fcd8a6ad6 | ||
|
|
fb4f8e820c | ||
|
|
9731afb776 | ||
|
|
f6634f1d00 | ||
|
|
18601db4f8 | ||
|
|
a65c69b0ce | ||
|
|
8f5c13f162 | ||
|
|
168225d67c | ||
|
|
401a1f359f | ||
|
|
82c8401167 | ||
|
|
2f803b2740 | ||
|
|
da0d5495d0 | ||
|
|
513a7290b0 | ||
|
|
0f8b582813 | ||
|
|
4026bb9003 | ||
|
|
f2f9a1bf03 | ||
|
|
76031de8eb | ||
|
|
e2874528cd | ||
|
|
aa127de9bd | ||
|
|
65a8048e25 | ||
|
|
1ab063486c | ||
|
|
0a1075e03f | ||
|
|
ca212e871f | ||
|
|
0525e66d55 | ||
|
|
acf6fc05ad | ||
|
|
f950e4e826 | ||
|
|
db2fc33e99 | ||
|
|
28dea45cc3 | ||
|
|
11f6f9e2a2 | ||
|
|
4771832492 | ||
|
|
c006113db9 | ||
|
|
5160009175 | ||
|
|
4009781064 | ||
|
|
761c903111 | ||
|
|
931a8dac95 | ||
|
|
3f717e3266 | ||
|
|
203b7d2b08 | ||
|
|
e9b03ee6a9 | ||
|
|
ba04e62f87 | ||
|
|
fa4bfb8e5c | ||
|
|
fde75f3fcf | ||
|
|
03a1a86cdb | ||
|
|
55ffaa1c5c | ||
|
|
1fdde95b09 | ||
|
|
c056d804e6 | ||
|
|
490382b5de | ||
|
|
557b62ac5c | ||
|
|
4ccc8d69d0 | ||
|
|
c3f487f16c | ||
|
|
6e6663376d | ||
|
|
041bbdc2e6 | ||
|
|
08f7ae9a5c | ||
|
|
c01a07bd82 | ||
|
|
b07391541b |
@@ -28,6 +28,10 @@ jobs:
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run unit and component tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
@@ -186,6 +190,7 @@ jobs:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
E2E_USERNAME: admin
|
||||
E2E_PASSWORD: admin123
|
||||
E2E_BACKEND_URL: http://localhost:8080
|
||||
|
||||
- name: Upload E2E results
|
||||
if: always()
|
||||
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
cd frontend && npm run lint
|
||||
@@ -8,7 +8,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Collaboration
|
||||
|
||||
See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, the Research → Plan → Implement → Validate cycle, and code style expectations.
|
||||
See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, and the Research → Plan → Implement → Validate cycle.
|
||||
|
||||
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
|
||||
|
||||
---
|
||||
|
||||
|
||||
329
CODESTYLE.md
Normal file
329
CODESTYLE.md
Normal 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`.
|
||||
@@ -43,6 +43,42 @@ Repeat for each new behavior.
|
||||
- The Refactor step must not change behavior — if a test breaks, the refactor introduced a bug.
|
||||
- If a bug is reported with no test, write the failing test first, then fix it.
|
||||
|
||||
## User Journeys & E2E Acceptance Criteria
|
||||
|
||||
Every `feature` issue must include two sections before any implementation begins:
|
||||
|
||||
### 1. User Journey
|
||||
|
||||
A plain-prose description of the steps a user takes to get value from the feature. Written from the user's perspective, not the implementation's:
|
||||
|
||||
> User opens a document, clicks "History", sees a chronological list of changes with editor name and timestamp. Clicking a row expands the old vs. new values.
|
||||
|
||||
This makes the scope concrete and prevents scope creep — anything not in the journey is out of scope for the issue.
|
||||
|
||||
### 2. E2E Scenarios
|
||||
|
||||
One or more acceptance criteria written as Playwright-ready scenarios. These become the outermost Red test in the TDD cycle — no feature is considered done until all its E2E scenarios pass:
|
||||
|
||||
```
|
||||
Scenario: View edit history of a document
|
||||
Given I am on a document detail page
|
||||
When I click the "History" tab
|
||||
Then I see at least one revision entry
|
||||
And each entry shows the editor's name and a timestamp
|
||||
```
|
||||
|
||||
Use this format consistently. It maps directly to `test.describe` / `test` blocks in the Playwright spec.
|
||||
|
||||
### Where this fits in the workflow
|
||||
|
||||
```
|
||||
Issue (Journey + Scenarios) → Red E2E test → Implementation → Green
|
||||
```
|
||||
|
||||
The scenarios in the issue are the contract. Write them before planning, treat them as failing tests from day one.
|
||||
|
||||
---
|
||||
|
||||
## Issue Tracking (Gitea)
|
||||
|
||||
All work is tracked in **Gitea** at `http://192.168.178.71:3005` (repo `marcel/familienarchiv`). Never use todo files or CLAUDE.md notes as a substitute.
|
||||
@@ -122,9 +158,30 @@ Closes #7
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
## Code Style Reminders
|
||||
### Atomic commits
|
||||
|
||||
Each commit must do exactly one logical thing. Never bundle multiple unrelated changes into a single commit, even if they are small.
|
||||
|
||||
**Wrong** — three changes in one commit:
|
||||
```
|
||||
fix(e2e+i18n): add missing DE translation, fix test selectors, fix lang switching
|
||||
```
|
||||
|
||||
**Right** — three separate commits:
|
||||
```
|
||||
fix(i18n): add missing person_btn_conversations DE translation
|
||||
fix(e2e): exclude /persons/new from person link selector
|
||||
fix(e2e): clear locale cookie when switching back to base language
|
||||
```
|
||||
|
||||
When in doubt, commit more often rather than less.
|
||||
|
||||
## Code Style
|
||||
|
||||
See [CODESTYLE.md](./CODESTYLE.md) for the full guide: Clean Code (Uncle Bob), DRY/KISS trade-offs, and SOLID principles applied to this stack.
|
||||
|
||||
Quick reminders:
|
||||
- Pure functions over stateful helpers where possible
|
||||
- No premature abstractions — solve the problem in front of you
|
||||
- No premature abstractions — KISS beats DRY
|
||||
- No backwards-compatibility shims for code that has no callers
|
||||
- Validate at system boundaries only (user input, external APIs)
|
||||
|
||||
@@ -119,6 +119,10 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
|
||||
@@ -6,10 +6,12 @@ import java.util.concurrent.ThreadPoolExecutor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class AsyncConfig {
|
||||
@Bean
|
||||
public Executor taskExecutor() {
|
||||
|
||||
@@ -43,8 +43,8 @@ public class DataInitializer {
|
||||
@Bean
|
||||
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
|
||||
return args -> {
|
||||
if (userRepository.count() == 0) {
|
||||
log.info("Keine User gefunden. Erstelle Default-Admin...");
|
||||
if (userRepository.findByUsername(adminUsername).isEmpty()) {
|
||||
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminUsername);
|
||||
|
||||
// 1. Admin Gruppe erstellen
|
||||
UserGroup adminGroup = UserGroup.builder()
|
||||
@@ -81,7 +81,8 @@ public class DataInitializer {
|
||||
@Profile("e2e")
|
||||
public CommandLineRunner initE2EData(PersonRepository personRepo,
|
||||
DocumentRepository docRepo,
|
||||
TagRepository tagRepo) {
|
||||
TagRepository tagRepo,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
return args -> {
|
||||
if (personRepo.count() > 0) {
|
||||
log.info("E2E seed: Daten bereits vorhanden, überspringe.");
|
||||
@@ -165,8 +166,21 @@ public class DataInitializer {
|
||||
.receivers(Set.of(otto))
|
||||
.build());
|
||||
|
||||
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente erstellt.",
|
||||
personRepo.count(), tagRepo.count(), docRepo.count());
|
||||
// ── Read-only user (for permissions E2E tests) ───────────────────
|
||||
// Username: reader / Password: reader123
|
||||
// Has only READ_ALL — used to assert write controls are absent.
|
||||
UserGroup leserGroup = groupRepository.save(UserGroup.builder()
|
||||
.name("Leser")
|
||||
.permissions(Set.of("READ_ALL"))
|
||||
.build());
|
||||
userRepository.save(AppUser.builder()
|
||||
.username("reader")
|
||||
.password(passwordEncoder.encode("reader123"))
|
||||
.groups(Set.of(leserGroup))
|
||||
.build());
|
||||
|
||||
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.",
|
||||
personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Health endpoint must be open so CI/Docker health checks work without credentials
|
||||
auth.requestMatchers("/actuator/health").permitAll();
|
||||
// Password reset endpoints are unauthenticated by nature
|
||||
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||
// E2E test helper (only active under "e2e" profile)
|
||||
auth.requestMatchers("/api/auth/reset-token-for-test").permitAll();
|
||||
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
|
||||
if (environment.matchesProfiles("dev")) {
|
||||
auth.requestMatchers(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.BackfillResult;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.MassImportService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -18,6 +21,8 @@ import lombok.RequiredArgsConstructor;
|
||||
public class AdminController {
|
||||
|
||||
private final MassImportService massImportService;
|
||||
private final DocumentService documentService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
|
||||
@PostMapping("/trigger-import")
|
||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
||||
@@ -29,4 +34,11 @@ public class AdminController {
|
||||
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
|
||||
return ResponseEntity.ok(massImportService.getStatus());
|
||||
}
|
||||
|
||||
@PostMapping("/backfill-versions")
|
||||
public ResponseEntity<BackfillResult> backfillVersions() {
|
||||
int count = documentVersionService.backfillMissingVersions(
|
||||
documentService.getDocumentsWithoutVersions());
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
|
||||
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.service.PasswordResetService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final PasswordResetService passwordResetService;
|
||||
|
||||
@Value("${app.base-url:http://localhost:3000}")
|
||||
private String appBaseUrl;
|
||||
|
||||
@PostMapping("/forgot-password")
|
||||
public ResponseEntity<Void> forgotPassword(@RequestBody ForgotPasswordRequest request) {
|
||||
passwordResetService.requestReset(request.getEmail(), appBaseUrl);
|
||||
// Always return 204 — never disclose whether the email exists
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/reset-password")
|
||||
public ResponseEntity<Void> resetPassword(@RequestBody ResetPasswordRequest request) {
|
||||
passwordResetService.resetPassword(request);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Test-only endpoint to retrieve a password reset token by email.
|
||||
* Only active under the "e2e" Spring profile.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@Profile("e2e")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthE2EController {
|
||||
|
||||
private final PasswordResetTokenRepository tokenRepository;
|
||||
|
||||
@GetMapping("/reset-token-for-test")
|
||||
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
|
||||
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,18 @@ import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.FileService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -39,6 +44,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService documentService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
private final FileService fileService;
|
||||
|
||||
// --- DOWNLOAD ---
|
||||
@@ -108,6 +114,18 @@ public class DocumentController {
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags));
|
||||
}
|
||||
|
||||
// --- VERSIONS ---
|
||||
|
||||
@GetMapping("/{id}/versions")
|
||||
public List<DocumentVersionSummary> getVersions(@PathVariable UUID id) {
|
||||
return documentVersionService.getSummaries(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/versions/{versionId}")
|
||||
public DocumentVersion getVersion(@PathVariable UUID id, @PathVariable UUID versionId) {
|
||||
return documentVersionService.getVersion(id, versionId);
|
||||
}
|
||||
|
||||
@GetMapping("/conversation")
|
||||
public List<Document> getConversation(
|
||||
@RequestParam UUID senderId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
@@ -33,11 +34,23 @@ public class PersonController {
|
||||
return personService.getById(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/correspondents")
|
||||
public ResponseEntity<List<Person>> getCorrespondents(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam(required = false) String q) {
|
||||
return ResponseEntity.ok(personService.findCorrespondents(id, q));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/documents")
|
||||
public List<Document> getPersonDocuments(@PathVariable UUID id) {
|
||||
return documentService.getDocumentsBySender(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/received-documents")
|
||||
public List<Document> getPersonReceivedDocuments(@PathVariable UUID id) {
|
||||
return documentService.getDocumentsByReceiver(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
|
||||
String firstName = body.get("firstName");
|
||||
@@ -49,13 +62,14 @@ public class PersonController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
|
||||
String firstName = body.get("firstName");
|
||||
String lastName = body.get("lastName");
|
||||
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) {
|
||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias")));
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/merge")
|
||||
|
||||
@@ -4,7 +4,10 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
@@ -16,8 +19,10 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -33,13 +38,32 @@ public class UserController {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
// Fetch full user object from DB to get latest permissions/groups
|
||||
AppUser user = userService.findByUsername(authentication.getName());
|
||||
|
||||
// Security: Remove password before sending
|
||||
user.setPassword(null);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
@PutMapping("users/me")
|
||||
public ResponseEntity<AppUser> updateProfile(Authentication authentication,
|
||||
@RequestBody UpdateProfileDTO dto) {
|
||||
AppUser current = userService.findByUsername(authentication.getName());
|
||||
AppUser updated = userService.updateProfile(current.getId(), dto);
|
||||
updated.setPassword(null);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@PostMapping("users/me/password")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void changePassword(Authentication authentication,
|
||||
@RequestBody ChangePasswordDTO dto) {
|
||||
AppUser current = userService.findByUsername(authentication.getName());
|
||||
userService.changePassword(current.getId(), dto);
|
||||
}
|
||||
|
||||
@GetMapping("users/{id}")
|
||||
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {
|
||||
AppUser user = userService.getById(id);
|
||||
user.setPassword(null);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
@@ -56,6 +80,15 @@ public class UserController {
|
||||
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
||||
}
|
||||
|
||||
@PutMapping("/users/{id}")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
|
||||
@RequestBody AdminUpdateUserRequest dto) {
|
||||
AppUser updated = userService.adminUpdateUser(id, dto);
|
||||
updated.setPassword(null);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{id}")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
public class AdminUpdateUserRequest {
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private LocalDate birthDate;
|
||||
private String email;
|
||||
private String contact;
|
||||
private String newPassword;
|
||||
private List<UUID> groupIds;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BackfillResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ChangePasswordDTO {
|
||||
private String currentPassword;
|
||||
private String newPassword;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -11,5 +12,9 @@ public class CreateUserRequest {
|
||||
private String username;
|
||||
private String email;
|
||||
private String initialPassword;
|
||||
private List<UUID> groupIds; // In welche Gruppen soll der User?
|
||||
private List<UUID> groupIds;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private LocalDate birthDate;
|
||||
private String contact;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DocumentVersionSummary(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime savedAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String editorName,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<String> changedFields
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ForgotPasswordRequest {
|
||||
private String email;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ResetPasswordRequest {
|
||||
private String token;
|
||||
private String newPassword;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ public enum ErrorCode {
|
||||
// --- Users ---
|
||||
/** A user with the given ID or username does not exist. 404 */
|
||||
USER_NOT_FOUND,
|
||||
/** The supplied email address is already used by another account. 409 */
|
||||
EMAIL_ALREADY_IN_USE,
|
||||
/** The supplied current password does not match the stored hash. 400 */
|
||||
WRONG_CURRENT_PASSWORD,
|
||||
|
||||
// --- Import ---
|
||||
/** A mass import is already in progress; only one can run at a time. 409 */
|
||||
@@ -31,6 +35,8 @@ public enum ErrorCode {
|
||||
UNAUTHORIZED,
|
||||
/** The authenticated user lacks the required permission. 403 */
|
||||
FORBIDDEN,
|
||||
/** The password-reset token is missing, expired, or already used. 400 */
|
||||
INVALID_RESET_TOKEN,
|
||||
|
||||
// --- Generic ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "document_versions")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DocumentVersion {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "document_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID documentId;
|
||||
|
||||
@Column(name = "saved_at", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime savedAt;
|
||||
|
||||
@Column(name = "editor_id")
|
||||
private UUID editorId;
|
||||
|
||||
@Column(name = "editor_name", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String editorName;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(columnDefinition = "jsonb", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String snapshot;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "changed_fields", columnDefinition = "jsonb", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String changedFields;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Table(name = "password_reset_tokens")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PasswordResetToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private AppUser user;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
private String token;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private boolean used = false;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -30,8 +30,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
List<Document> findBySenderId(UUID senderId);
|
||||
|
||||
List<Document> findByReceiversId(UUID receiverId);
|
||||
|
||||
List<Document> findByTags_Id(UUID tagId);
|
||||
|
||||
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||
List<Document> findDocumentsWithoutVersions();
|
||||
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"JOIN d.receivers r " +
|
||||
"WHERE " +
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface DocumentVersionRepository extends JpaRepository<DocumentVersion, UUID> {
|
||||
|
||||
List<DocumentVersion> findByDocumentIdOrderBySavedAtAsc(UUID documentId);
|
||||
|
||||
Optional<DocumentVersion> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, UUID> {
|
||||
|
||||
Optional<PasswordResetToken> findByToken(String token);
|
||||
|
||||
@Query("SELECT t.token FROM PasswordResetToken t WHERE t.user.email = :email AND t.used = false AND t.expiresAt > :now ORDER BY t.expiresAt DESC LIMIT 1")
|
||||
Optional<String> findLatestActiveTokenByEmail(String email, LocalDateTime now);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM PasswordResetToken t WHERE t.expiresAt < :now OR t.used = true")
|
||||
void deleteExpiredAndUsed(LocalDateTime now);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -29,14 +29,15 @@ import java.util.UUID;
|
||||
import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor // Lombok: Erzeugt Constructor für 'final' Felder (Dependency Injection)
|
||||
@Slf4j // Lombok: Logging
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DocumentService {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final PersonService personService;
|
||||
private final FileService fileService;
|
||||
private final TagService tagService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
|
||||
/**
|
||||
* Lädt eine Datei hoch.
|
||||
@@ -102,8 +103,10 @@ public class DocumentService {
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
updateDocumentTags(doc.getId(), tags);
|
||||
doc = documentRepository.findById(doc.getId()).orElseThrow();
|
||||
UUID savedId = doc.getId();
|
||||
updateDocumentTags(savedId, tags);
|
||||
doc = documentRepository.findById(savedId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found after save: " + savedId));
|
||||
|
||||
// Sender
|
||||
if (dto.getSenderId() != null) {
|
||||
@@ -123,7 +126,9 @@ public class DocumentService {
|
||||
doc.setStatus(DocumentStatus.UPLOADED);
|
||||
}
|
||||
|
||||
return documentRepository.save(doc);
|
||||
Document finalDoc = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(finalDoc);
|
||||
return finalDoc;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -176,11 +181,14 @@ public class DocumentService {
|
||||
doc.setStatus(DocumentStatus.UPLOADED);
|
||||
}
|
||||
|
||||
return documentRepository.save(doc);
|
||||
Document saved = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||
Document doc = documentRepository.findById(docId).orElseThrow();
|
||||
Document doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||
|
||||
Set<Tag> newTags = new HashSet<>();
|
||||
|
||||
@@ -217,12 +225,11 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID reciever, List<String> tags) {
|
||||
log.info("Tags", tags);
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags) {
|
||||
Specification<Document> spec = Specification.where(hasText(text))
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(reciever))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(tags));
|
||||
|
||||
// Immer sortiert nach Datum
|
||||
@@ -250,10 +257,18 @@ public class DocumentService {
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsWithoutVersions() {
|
||||
return documentRepository.findDocumentsWithoutVersions();
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||
return documentRepository.findBySenderId(senderId);
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsByReceiver(UUID receiverId) {
|
||||
return documentRepository.findByReceiversId(receiverId);
|
||||
}
|
||||
|
||||
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentVersionRepository;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DocumentVersionService {
|
||||
|
||||
private final DocumentVersionRepository versionRepository;
|
||||
private final UserService userService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public void recordVersion(Document doc) {
|
||||
AppUser editor = resolveCurrentUser();
|
||||
String editorName = buildEditorName(editor);
|
||||
UUID editorId = editor != null ? editor.getId() : null;
|
||||
|
||||
String snapshot = serializeSnapshot(doc);
|
||||
List<DocumentVersion> previous = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId());
|
||||
String changedFields = computeChangedFields(doc, previous);
|
||||
|
||||
versionRepository.save(DocumentVersion.builder()
|
||||
.documentId(doc.getId())
|
||||
.savedAt(LocalDateTime.now())
|
||||
.editorId(editorId)
|
||||
.editorName(editorName)
|
||||
.snapshot(snapshot)
|
||||
.changedFields(changedFields)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int backfillMissingVersions(List<Document> docs) {
|
||||
int count = 0;
|
||||
for (Document doc : docs) {
|
||||
List<DocumentVersion> existing = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId());
|
||||
if (!existing.isEmpty()) continue;
|
||||
LocalDateTime savedAt = doc.getCreatedAt() != null ? doc.getCreatedAt() : LocalDateTime.now();
|
||||
versionRepository.save(DocumentVersion.builder()
|
||||
.documentId(doc.getId())
|
||||
.savedAt(savedAt)
|
||||
.editorId(null)
|
||||
.editorName("Datenimport")
|
||||
.snapshot(serializeSnapshot(doc))
|
||||
.changedFields("[]")
|
||||
.build());
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public List<DocumentVersionSummary> getSummaries(UUID documentId) {
|
||||
return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream()
|
||||
.map(v -> new DocumentVersionSummary(
|
||||
v.getId(),
|
||||
v.getSavedAt(),
|
||||
v.getEditorName(),
|
||||
parseChangedFields(v.getChangedFields())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public DocumentVersion getVersion(UUID documentId, UUID versionId) {
|
||||
return versionRepository.findByIdAndDocumentId(versionId, documentId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND,
|
||||
"Version not found: " + versionId));
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private AppUser resolveCurrentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return userService.findByUsername(auth.getName());
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not resolve editor for version snapshot: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildEditorName(AppUser user) {
|
||||
if (user == null) return "Unknown";
|
||||
String first = user.getFirstName();
|
||||
String last = user.getLastName();
|
||||
if (first != null && !first.isBlank() && last != null && !last.isBlank()) {
|
||||
return first + " " + last;
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
private String serializeSnapshot(Document doc) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(doc);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to serialize document snapshot for {}", doc.getId(), e);
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
private String computeChangedFields(Document current, List<DocumentVersion> previousVersions) {
|
||||
if (previousVersions.isEmpty()) {
|
||||
return "[]";
|
||||
}
|
||||
DocumentVersion last = previousVersions.get(previousVersions.size() - 1);
|
||||
try {
|
||||
Map<String, Object> previousMap = objectMapper.readValue(
|
||||
last.getSnapshot(), new TypeReference<>() {});
|
||||
List<String> changed = new ArrayList<>();
|
||||
|
||||
checkScalar(changed, "title", current.getTitle(), previousMap);
|
||||
checkScalar(changed, "documentDate",
|
||||
current.getDocumentDate() != null ? current.getDocumentDate().toString() : null,
|
||||
previousMap);
|
||||
checkScalar(changed, "location", current.getLocation(), previousMap);
|
||||
checkScalar(changed, "documentLocation", current.getDocumentLocation(), previousMap);
|
||||
checkScalar(changed, "transcription", current.getTranscription(), previousMap);
|
||||
checkScalar(changed, "summary", current.getSummary(), previousMap);
|
||||
checkSender(changed, current, previousMap);
|
||||
checkReceivers(changed, current, previousMap);
|
||||
checkTags(changed, current, previousMap);
|
||||
|
||||
return objectMapper.writeValueAsString(changed);
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not compute changedFields for document {}", current.getId(), e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
private void checkScalar(List<String> changed, String field, String currentValue,
|
||||
Map<String, Object> previousMap) {
|
||||
Object prev = previousMap.get(field);
|
||||
String prevStr = prev != null ? prev.toString() : null;
|
||||
if (!Objects.equals(currentValue, prevStr)) {
|
||||
changed.add(field);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void checkSender(List<String> changed, Document current, Map<String, Object> previousMap) {
|
||||
String currentId = current.getSender() != null
|
||||
? current.getSender().getId().toString() : null;
|
||||
Object prevSender = previousMap.get("sender");
|
||||
String prevId = null;
|
||||
if (prevSender instanceof Map<?, ?> senderMap) {
|
||||
Object id = senderMap.get("id");
|
||||
prevId = id != null ? id.toString() : null;
|
||||
}
|
||||
if (!Objects.equals(currentId, prevId)) {
|
||||
changed.add("sender");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void checkReceivers(List<String> changed, Document current, Map<String, Object> previousMap) {
|
||||
Set<String> currentIds = current.getReceivers() != null
|
||||
? current.getReceivers().stream().map(p -> p.getId().toString()).collect(Collectors.toSet())
|
||||
: Set.of();
|
||||
Object prevReceivers = previousMap.get("receivers");
|
||||
Set<String> prevIds = Set.of();
|
||||
if (prevReceivers instanceof List<?> list) {
|
||||
prevIds = list.stream()
|
||||
.filter(r -> r instanceof Map<?, ?>)
|
||||
.map(r -> ((Map<?, ?>) r).get("id"))
|
||||
.filter(Objects::nonNull)
|
||||
.map(Object::toString)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
if (!currentIds.equals(prevIds)) {
|
||||
changed.add("receivers");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void checkTags(List<String> changed, Document current, Map<String, Object> previousMap) {
|
||||
Set<String> currentNames = current.getTags() != null
|
||||
? current.getTags().stream().map(Tag::getName).collect(Collectors.toSet())
|
||||
: Set.of();
|
||||
Object prevTags = previousMap.get("tags");
|
||||
Set<String> prevNames = Set.of();
|
||||
if (prevTags instanceof List<?> list) {
|
||||
prevNames = list.stream()
|
||||
.filter(t -> t instanceof Map<?, ?>)
|
||||
.map(t -> ((Map<?, ?>) t).get("name"))
|
||||
.filter(Objects::nonNull)
|
||||
.map(Object::toString)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
if (!currentNames.equals(prevNames)) {
|
||||
changed.add("tags");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseChangedFields(String json) {
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.MailException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PasswordResetService {
|
||||
|
||||
private final AppUserRepository userRepository;
|
||||
private final PasswordResetTokenRepository tokenRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
||||
private String mailFrom;
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
private static final int TOKEN_EXPIRY_HOURS = 1;
|
||||
|
||||
/**
|
||||
* Creates a reset token for the given email address and sends it via email.
|
||||
* If the email is not found, silently does nothing (prevents user enumeration).
|
||||
* If no mail sender is configured, logs a warning.
|
||||
*/
|
||||
public void requestReset(String email, String appBaseUrl) {
|
||||
Optional<AppUser> userOpt = userRepository.findByEmail(email);
|
||||
if (userOpt.isEmpty()) {
|
||||
log.debug("Password reset requested for unknown email: {}", email);
|
||||
return;
|
||||
}
|
||||
|
||||
AppUser user = userOpt.get();
|
||||
String token = generateToken();
|
||||
|
||||
tokenRepository.save(PasswordResetToken.builder()
|
||||
.user(user)
|
||||
.token(token)
|
||||
.expiresAt(LocalDateTime.now().plusHours(TOKEN_EXPIRY_HOURS))
|
||||
.build());
|
||||
|
||||
sendResetEmail(user.getEmail(), token, appBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the token and updates the user's password.
|
||||
*/
|
||||
@Transactional
|
||||
public void resetPassword(ResetPasswordRequest request) {
|
||||
PasswordResetToken resetToken = tokenRepository.findByToken(request.getToken())
|
||||
.orElseThrow(() -> DomainException.badRequest(
|
||||
ErrorCode.INVALID_RESET_TOKEN, "Invalid or unknown reset token"));
|
||||
|
||||
if (resetToken.isUsed() || resetToken.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_RESET_TOKEN, "Token expired or already used");
|
||||
}
|
||||
|
||||
AppUser user = resetToken.getUser();
|
||||
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
|
||||
userRepository.save(user);
|
||||
|
||||
resetToken.setUsed(true);
|
||||
tokenRepository.save(resetToken);
|
||||
}
|
||||
|
||||
/** Nightly cleanup of expired and used tokens. */
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
@Transactional
|
||||
public void cleanupExpiredTokens() {
|
||||
tokenRepository.deleteExpiredAndUsed(LocalDateTime.now());
|
||||
log.info("Cleaned up expired password reset tokens");
|
||||
}
|
||||
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
SECURE_RANDOM.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private void sendResetEmail(String to, String token, String appBaseUrl) {
|
||||
if (mailSender == null) {
|
||||
log.warn("Mail sender not configured — skipping password reset email to {}", to);
|
||||
return;
|
||||
}
|
||||
|
||||
String resetUrl = appBaseUrl + "/reset-password?token=" + token;
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(mailFrom);
|
||||
message.setTo(to);
|
||||
message.setSubject("Passwort zurücksetzen — Familienarchiv");
|
||||
message.setText(
|
||||
"Hallo,\n\n"
|
||||
+ "Sie haben eine Passwort-Zurücksetzung beantragt.\n\n"
|
||||
+ "Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:\n"
|
||||
+ resetUrl + "\n\n"
|
||||
+ "Der Link ist " + TOKEN_EXPIRY_HOURS + " Stunde(n) gültig.\n\n"
|
||||
+ "Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n"
|
||||
+ "Ihr Familienarchiv-Team");
|
||||
|
||||
try {
|
||||
mailSender.send(message);
|
||||
log.info("Password reset email sent to {}", to);
|
||||
} catch (MailException e) {
|
||||
log.error("Failed to send password reset email to {}: {}", to, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -30,6 +31,13 @@ public class PersonService {
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
||||
}
|
||||
|
||||
public List<Person> findCorrespondents(UUID personId, String q) {
|
||||
if (q != null && !q.isBlank()) {
|
||||
return personRepository.findCorrespondentsWithFilter(personId, q);
|
||||
}
|
||||
return personRepository.findCorrespondents(personId);
|
||||
}
|
||||
|
||||
public List<Person> getAllById(List<UUID> ids) {
|
||||
return personRepository.findAllById(ids);
|
||||
}
|
||||
@@ -58,12 +66,18 @@ public class PersonService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, String firstName, String lastName, String alias) {
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
||||
}
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
||||
person.setFirstName(firstName);
|
||||
person.setLastName(lastName);
|
||||
person.setAlias(alias == null || alias.isBlank() ? null : alias.trim());
|
||||
person.setFirstName(dto.getFirstName());
|
||||
person.setLastName(dto.getLastName());
|
||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||
person.setBirthYear(dto.getBirthYear());
|
||||
person.setDeathYear(dto.getDeathYear());
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.dto.GroupDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
@@ -30,49 +33,121 @@ public class UserService {
|
||||
private final UserGroupRepository groupRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Transactional
|
||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||
log.info("Versuche neuen User anzulegen: {}", request.getUsername());
|
||||
@Transactional
|
||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||
log.info("Creating or updating user: {}", request.getUsername());
|
||||
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||
List<UserGroup> foundGroups = groupRepository.findAllById(request.getGroupIds());
|
||||
groups.addAll(foundGroups);
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
|
||||
}
|
||||
|
||||
Optional<AppUser> existingUser = userRepository.findByUsername(request.getUsername());
|
||||
AppUser user;
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
log.info("User exists, updating: {}", request.getUsername());
|
||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||
} else {
|
||||
log.info("Creating new user: {}", request.getUsername());
|
||||
user = AppUser.builder()
|
||||
.username(request.getUsername())
|
||||
.email(request.getEmail())
|
||||
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||
.groups(groups)
|
||||
.firstName(request.getFirstName())
|
||||
.lastName(request.getLastName())
|
||||
.birthDate(request.getBirthDate())
|
||||
.contact(request.getContact())
|
||||
.enabled(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
log.info("GroupsIds {}", groups.toString());
|
||||
log.info("Groupds in DB {}", groupRepository.findAll().toString());
|
||||
|
||||
Optional<AppUser> dbUser = userRepository.findByUsername(request.getUsername());
|
||||
AppUser user;
|
||||
|
||||
if (dbUser.isPresent()) {
|
||||
log.info("Found user in DB. Will update.");
|
||||
user = dbUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||
} else {
|
||||
log.info("Creating new user.");
|
||||
user = AppUser.builder()
|
||||
.username(request.getUsername())
|
||||
.email(request.getEmail())
|
||||
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||
.groups(groups)
|
||||
.enabled(true)
|
||||
.build();
|
||||
}
|
||||
log.info("Saving new user {}", user.toString());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
@Transactional
|
||||
public void deleteUser(UUID userId) {
|
||||
log.info("Delete user {}", userId);
|
||||
|
||||
AppUser user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for id %s", userId)));
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
public AppUser getById(UUID id) {
|
||||
return userRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
||||
AppUser user = getById(userId);
|
||||
|
||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||
userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
|
||||
if (!existing.getId().equals(userId)) {
|
||||
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE,
|
||||
"E-Mail wird bereits von einem anderen Konto verwendet");
|
||||
}
|
||||
});
|
||||
user.setEmail(dto.getEmail().trim());
|
||||
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) {
|
||||
user.setEmail(null);
|
||||
}
|
||||
|
||||
user.setFirstName(dto.getFirstName());
|
||||
user.setLastName(dto.getLastName());
|
||||
user.setBirthDate(dto.getBirthDate());
|
||||
user.setContact(dto.getContact() == null || dto.getContact().isBlank() ? null : dto.getContact().trim());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
|
||||
AppUser user = getById(id);
|
||||
|
||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||
userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
|
||||
if (!existing.getId().equals(id)) {
|
||||
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE,
|
||||
"E-Mail wird bereits von einem anderen Konto verwendet");
|
||||
}
|
||||
});
|
||||
user.setEmail(dto.getEmail().trim());
|
||||
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) {
|
||||
user.setEmail(null);
|
||||
}
|
||||
|
||||
user.setFirstName(dto.getFirstName());
|
||||
user.setLastName(dto.getLastName());
|
||||
user.setBirthDate(dto.getBirthDate());
|
||||
user.setContact(dto.getContact() == null || dto.getContact().isBlank() ? null : dto.getContact().trim());
|
||||
|
||||
if (dto.getNewPassword() != null && !dto.getNewPassword().isBlank()) {
|
||||
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
|
||||
}
|
||||
|
||||
if (dto.getGroupIds() != null) {
|
||||
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||
user.setGroups(groups);
|
||||
}
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
||||
AppUser user = getById(userId);
|
||||
if (!passwordEncoder.matches(dto.getCurrentPassword(), user.getPassword())) {
|
||||
throw DomainException.badRequest(ErrorCode.WRONG_CURRENT_PASSWORD,
|
||||
"Das aktuelle Passwort ist falsch");
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public AppUser findByUsername(String username) {
|
||||
return userRepository.findByUsername(username).orElseThrow(
|
||||
() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for username %s", username)));
|
||||
return userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for username: " + username));
|
||||
}
|
||||
|
||||
public List<AppUser> getAllUsers() {
|
||||
|
||||
@@ -24,6 +24,23 @@ spring:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
|
||||
mail:
|
||||
host: ${MAIL_HOST:}
|
||||
port: ${MAIL_PORT:587}
|
||||
username: ${MAIL_USERNAME:}
|
||||
password: ${MAIL_PASSWORD:}
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
|
||||
management:
|
||||
health:
|
||||
mail:
|
||||
enabled: false
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
@@ -38,6 +55,11 @@ app:
|
||||
bucket: ${S3_BUCKET_NAME}
|
||||
region: ${S3_REGION}
|
||||
|
||||
base-url: ${APP_BASE_URL:http://localhost:3000}
|
||||
|
||||
mail:
|
||||
from: ${APP_MAIL_FROM:noreply@familienarchiv.local}
|
||||
|
||||
admin:
|
||||
username: ${APP_ADMIN_USERNAME:admin}
|
||||
password: ${APP_ADMIN_PASSWORD:admin123}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE password_reset_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_prt_token ON password_reset_tokens(token);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE document_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
saved_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
editor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
editor_name VARCHAR(200) NOT NULL,
|
||||
snapshot JSONB NOT NULL,
|
||||
changed_fields JSONB NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE INDEX ON document_versions (document_id, saved_at DESC);
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.MassImportService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(AdminController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class AdminControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean MassImportService massImportService;
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean DocumentVersionService documentVersionService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
@Test
|
||||
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillVersions_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void backfillVersions_returns200_withCount_whenAdmin() throws Exception {
|
||||
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
||||
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(1));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.FileService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
@@ -15,13 +18,16 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(DocumentController.class)
|
||||
@@ -31,6 +37,7 @@ class DocumentControllerTest {
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean DocumentVersionService documentVersionService;
|
||||
@MockitoBean FileService fileService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
@@ -113,4 +120,48 @@ class DocumentControllerTest {
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getVersions_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getVersions_returns200_whenAuthenticated() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentVersionSummary summary = new DocumentVersionSummary(
|
||||
UUID.randomUUID(), LocalDateTime.now(), "Emma Müller", List.of("title"));
|
||||
when(documentVersionService.getSummaries(docId)).thenReturn(List.of(summary));
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + docId + "/versions"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].editorName").value("Emma Müller"));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions/{versionId} ────────────────────────
|
||||
|
||||
@Test
|
||||
void getVersion_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getVersion_returns200_whenAuthenticated() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID versionId = UUID.randomUUID();
|
||||
DocumentVersion version = DocumentVersion.builder()
|
||||
.id(versionId).documentId(docId).savedAt(LocalDateTime.now())
|
||||
.editorName("Otto").snapshot("{\"title\":\"Brief\"}").changedFields("[]").build();
|
||||
when(documentVersionService.getVersion(docId, versionId)).thenReturn(version);
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + docId + "/versions/" + versionId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
|
||||
@@ -29,6 +30,7 @@ class DocumentServiceTest {
|
||||
@Mock PersonService personService;
|
||||
@Mock FileService fileService;
|
||||
@Mock TagService tagService;
|
||||
@Mock DocumentVersionService documentVersionService;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── getDocumentById ──────────────────────────────────────────────────────
|
||||
@@ -121,4 +123,48 @@ class DocumentServiceTest {
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(documentRepository).save(any());
|
||||
}
|
||||
|
||||
// ─── getDocumentsByReceiver ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentsByReceiver_returnsDocumentsWherePersonIsReceiver() {
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
|
||||
when(documentRepository.findByReceiversId(receiverId)).thenReturn(List.of(doc));
|
||||
|
||||
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
||||
}
|
||||
|
||||
// ─── versioning ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createDocument_recordsVersionAfterSave() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Neuer Brief");
|
||||
|
||||
Document saved = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Neuer Brief")
|
||||
.originalFilename("Neuer Brief").status(DocumentStatus.PLACEHOLDER)
|
||||
.build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verify(documentVersionService, atLeastOnce()).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_recordsVersionAfterSave() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Alt").originalFilename("alt.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
documentService.updateDocument(id, new DocumentUpdateDTO(), null);
|
||||
|
||||
verify(documentVersionService).recordVersion(any(Document.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentVersionRepository;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentVersionServiceTest {
|
||||
|
||||
@Mock DocumentVersionRepository versionRepository;
|
||||
@Mock UserService userService;
|
||||
|
||||
private DocumentVersionService versionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
versionService = new DocumentVersionService(versionRepository, userService, new ObjectMapper());
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ─── recordVersion — editor name ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void recordVersion_usesFirstAndLastName_whenBothPresent() {
|
||||
authenticateAs("emma");
|
||||
when(userService.findByUsername("emma")).thenReturn(
|
||||
AppUser.builder().id(UUID.randomUUID()).username("emma")
|
||||
.firstName("Emma").lastName("Müller").build());
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.recordVersion(minimalDocument());
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getEditorName()).isEqualTo("Emma Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_usesUsername_whenNamesAreBlank() {
|
||||
authenticateAs("otto99");
|
||||
when(userService.findByUsername("otto99")).thenReturn(
|
||||
AppUser.builder().id(UUID.randomUUID()).username("otto99")
|
||||
.firstName(null).lastName(null).build());
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.recordVersion(minimalDocument());
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getEditorName()).isEqualTo("otto99");
|
||||
}
|
||||
|
||||
// ─── recordVersion — snapshot ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void recordVersion_savesSnapshotContainingTitle() {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Wichtiger Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.build();
|
||||
|
||||
versionService.recordVersion(doc);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSnapshot()).contains("Wichtiger Brief");
|
||||
assertThat(captor.getValue().getDocumentId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
// ─── recordVersion — changedFields ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void recordVersion_changedFieldsIsEmpty_forFirstVersion() {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.recordVersion(minimalDocument());
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_includesTitleInChangedFields_whenTitleChanged() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
Document oldDoc = Document.builder().id(UUID.randomUUID()).title("Alt").build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.documentId(oldDoc.getId())
|
||||
.snapshot(oldSnapshot)
|
||||
.changedFields("[]")
|
||||
.savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1")
|
||||
.build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(oldDoc.getId()))
|
||||
.thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document updated = Document.builder().id(oldDoc.getId()).title("Neu").build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("title");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_doesNotIncludeUnchangedFields_inChangedFields() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document oldDoc = Document.builder().id(docId).title("Same").location("Berlin").build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1").build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document updated = Document.builder().id(docId).title("Same").location("Hamburg").build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("location");
|
||||
assertThat(captor.getValue().getChangedFields()).doesNotContain("title");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_tracksSenderChange() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
UUID docId = UUID.randomUUID();
|
||||
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
||||
Document oldDoc = Document.builder().id(docId).title("T").sender(oldSender).build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1").build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Person newSender = Person.builder().id(UUID.randomUUID()).firstName("C").lastName("D").build();
|
||||
Document updated = Document.builder().id(docId).title("T").sender(newSender).build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("sender");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_tracksReceiverChange() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
UUID docId = UUID.randomUUID();
|
||||
Person receiver1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
||||
Document oldDoc = Document.builder().id(docId).title("T").receivers(Set.of(receiver1)).build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1").build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document updated = Document.builder().id(docId).title("T").receivers(Set.of()).build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("receivers");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_tracksTagChange() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
UUID docId = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
Document oldDoc = Document.builder().id(docId).title("T").tags(Set.of(tag)).build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1").build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document updated = Document.builder().id(docId).title("T").tags(Set.of()).build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("tags");
|
||||
}
|
||||
|
||||
// ─── getSummaries ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getSummaries_returnsListWithParsedChangedFields() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentVersion v = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId)
|
||||
.savedAt(LocalDateTime.now()).editorName("Emma Müller")
|
||||
.snapshot("{}").changedFields("[\"title\",\"location\"]")
|
||||
.build();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(v));
|
||||
|
||||
List<DocumentVersionSummary> summaries = versionService.getSummaries(docId);
|
||||
|
||||
assertThat(summaries).hasSize(1);
|
||||
assertThat(summaries.get(0).editorName()).isEqualTo("Emma Müller");
|
||||
assertThat(summaries.get(0).changedFields()).containsExactlyInAnyOrder("title", "location");
|
||||
assertThat(summaries.get(0).id()).isEqualTo(v.getId());
|
||||
}
|
||||
|
||||
// ─── getVersion ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getVersion_returnsVersion_whenFound() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID versionId = UUID.randomUUID();
|
||||
DocumentVersion v = DocumentVersion.builder()
|
||||
.id(versionId).documentId(docId).snapshot("{}")
|
||||
.changedFields("[]").editorName("x").savedAt(LocalDateTime.now()).build();
|
||||
when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.of(v));
|
||||
|
||||
assertThat(versionService.getVersion(docId, versionId)).isEqualTo(v);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVersion_throwsNotFound_whenVersionBelongsToOtherDocument() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID versionId = UUID.randomUUID();
|
||||
when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> versionService.getVersion(docId, versionId))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// ─── backfillMissingVersions ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfill_createsVersion_withEditorNameDatenimport() {
|
||||
Document doc = minimalDocument();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.backfillMissingVersions(List.of(doc));
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getEditorName()).isEqualTo("Datenimport");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_usesDocumentCreatedAt_asSavedAt() {
|
||||
LocalDateTime createdAt = LocalDateTime.of(2020, 3, 15, 10, 0);
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID()).title("T").createdAt(createdAt).build();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.backfillMissingVersions(List.of(doc));
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSavedAt()).isEqualTo(createdAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_setsChangedFieldsEmpty() {
|
||||
Document doc = minimalDocument();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.backfillMissingVersions(List.of(doc));
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_skipsDocuments_thatAlreadyHaveVersions() {
|
||||
Document doc = minimalDocument();
|
||||
DocumentVersion existing = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(doc.getId()).snapshot("{}")
|
||||
.changedFields("[]").editorName("user").savedAt(LocalDateTime.now()).build();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of(existing));
|
||||
|
||||
int count = versionService.backfillMissingVersions(List.of(doc));
|
||||
|
||||
verify(versionRepository, never()).save(any());
|
||||
assertThat(count).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_returnsCountOfCreatedVersions() {
|
||||
Document d1 = minimalDocument();
|
||||
Document d2 = minimalDocument();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d1.getId())).thenReturn(List.of());
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d2.getId())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
int count = versionService.backfillMissingVersions(List.of(d1, d2));
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void authenticateAs(String username) {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(username, null, List.of()));
|
||||
}
|
||||
|
||||
private AppUser stubUser(String username) {
|
||||
return AppUser.builder().id(UUID.randomUUID()).username(username)
|
||||
.firstName(null).lastName(null).build();
|
||||
}
|
||||
|
||||
private Document minimalDocument() {
|
||||
return Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Test")
|
||||
.originalFilename("test.pdf")
|
||||
.documentDate(LocalDate.of(1940, 5, 1))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PasswordResetServiceTest {
|
||||
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock PasswordResetTokenRepository tokenRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@Mock JavaMailSender mailSender;
|
||||
@InjectMocks PasswordResetService service;
|
||||
|
||||
private AppUser makeUser(String email) {
|
||||
return AppUser.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.username("testuser")
|
||||
.email(email)
|
||||
.password("hashed")
|
||||
.build();
|
||||
}
|
||||
|
||||
// ─── requestReset ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void requestReset_savesTokenForKnownEmail() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||
|
||||
service.requestReset("user@example.com", "http://localhost:3000");
|
||||
|
||||
verify(tokenRepository).save(argThat(t ->
|
||||
t.getUser().equals(user)
|
||||
&& t.getToken().length() == 64
|
||||
&& !t.isUsed()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestReset_doesNothingForUnknownEmail() {
|
||||
when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
service.requestReset("ghost@example.com", "http://localhost:3000");
|
||||
|
||||
verify(tokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// ─── resetPassword ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void resetPassword_updatesPasswordForValidToken() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
PasswordResetToken token = PasswordResetToken.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.token("validtoken123")
|
||||
.user(user)
|
||||
.expiresAt(LocalDateTime.now().plusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
|
||||
when(passwordEncoder.encode("newpass")).thenReturn("hashed-newpass");
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("validtoken123");
|
||||
req.setNewPassword("newpass");
|
||||
service.resetPassword(req);
|
||||
|
||||
verify(passwordEncoder).encode("newpass");
|
||||
verify(userRepository).save(argThat(u -> u.getPassword().equals("hashed-newpass")));
|
||||
assertThat(token.isUsed()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPassword_throwsForExpiredToken() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
PasswordResetToken token = PasswordResetToken.builder()
|
||||
.token("expiredtoken")
|
||||
.user(user)
|
||||
.expiresAt(LocalDateTime.now().minusMinutes(1))
|
||||
.used(false)
|
||||
.build();
|
||||
when(tokenRepository.findByToken("expiredtoken")).thenReturn(Optional.of(token));
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("expiredtoken");
|
||||
req.setNewPassword("newpass");
|
||||
|
||||
assertThatThrownBy(() -> service.resetPassword(req))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPassword_throwsForUnknownToken() {
|
||||
when(tokenRepository.findByToken("nosuchtoken")).thenReturn(Optional.empty());
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("nosuchtoken");
|
||||
req.setNewPassword("newpass");
|
||||
|
||||
assertThatThrownBy(() -> service.resetPassword(req))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -83,6 +85,115 @@ class PersonServiceTest {
|
||||
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
|
||||
}
|
||||
|
||||
// ─── updatePerson (notes) ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsNotes() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes("Some notes here.");
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getNotes()).isEqualTo("Some notes here.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_clearsNotes_whenBlank() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").notes("old notes").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes(" ");
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getNotes()).isNull();
|
||||
}
|
||||
|
||||
// ─── updatePerson (birth/death years) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsBirthAndDeathYear() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1890);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1965);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_allowsSameYear() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1900);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1900);
|
||||
}
|
||||
|
||||
// ─── findCorrespondents ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findCorrespondents_delegatesToRepository_withoutFilter() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build());
|
||||
when(personRepository.findCorrespondents(personId)).thenReturn(expected);
|
||||
|
||||
assertThat(personService.findCorrespondents(personId, null)).isEqualTo(expected);
|
||||
verify(personRepository).findCorrespondents(personId);
|
||||
verify(personRepository, never()).findCorrespondentsWithFilter(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findCorrespondents_delegatesToRepository_withFilter() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build());
|
||||
when(personRepository.findCorrespondentsWithFilter(personId, "Anna")).thenReturn(expected);
|
||||
|
||||
assertThat(personService.findCorrespondents(personId, "Anna")).isEqualTo(expected);
|
||||
verify(personRepository).findCorrespondentsWithFilter(personId, "Anna");
|
||||
verify(personRepository, never()).findCorrespondents(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findCorrespondents_delegatesToRepository_withBlankFilter() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(personRepository.findCorrespondents(personId)).thenReturn(List.of());
|
||||
|
||||
personService.findCorrespondents(personId, " ");
|
||||
|
||||
verify(personRepository).findCorrespondents(personId);
|
||||
verify(personRepository, never()).findCorrespondentsWithFilter(any(), any());
|
||||
}
|
||||
|
||||
// ─── mergePersons ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,7 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
@@ -20,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
@@ -109,6 +112,110 @@ class UserServiceTest {
|
||||
verify(userRepository, times(1)).save(existing);
|
||||
}
|
||||
|
||||
// ─── getById ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.getById(id))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returnsUser_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
|
||||
assertThat(userService.getById(id)).isEqualTo(user);
|
||||
}
|
||||
|
||||
// ─── updateProfile ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateProfile_updatesFields() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||
dto.setFirstName("Max"); dto.setLastName("Müller"); dto.setEmail("max@example.com");
|
||||
AppUser result = userService.updateProfile(id, dto);
|
||||
|
||||
assertThat(result.getFirstName()).isEqualTo("Max");
|
||||
assertThat(result.getLastName()).isEqualTo("Müller");
|
||||
assertThat(result.getEmail()).isEqualTo("max@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfile_throwsConflict_whenEmailTakenByAnotherUser() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID otherId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||
AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
|
||||
|
||||
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||
dto.setEmail("taken@example.com");
|
||||
|
||||
assertThatThrownBy(() -> userService.updateProfile(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("E-Mail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfile_allowsSameEmailForSameUser() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").email("max@example.com").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||
dto.setEmail("max@example.com");
|
||||
dto.setFirstName("Max");
|
||||
|
||||
assertThat(userService.updateProfile(id, dto).getEmail()).isEqualTo("max@example.com");
|
||||
}
|
||||
|
||||
// ─── changePassword ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void changePassword_throwsBadRequest_whenCurrentPasswordWrong() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("wrong", "hashed")).thenReturn(false);
|
||||
|
||||
ChangePasswordDTO dto = new ChangePasswordDTO();
|
||||
dto.setCurrentPassword("wrong"); dto.setNewPassword("newpass");
|
||||
|
||||
assertThatThrownBy(() -> userService.changePassword(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("Passwort");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changePassword_updatesHash_whenCurrentPasswordCorrect() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("correct", "hashed")).thenReturn(true);
|
||||
when(passwordEncoder.encode("newpass")).thenReturn("newHash");
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
ChangePasswordDTO dto = new ChangePasswordDTO();
|
||||
dto.setCurrentPassword("correct"); dto.setNewPassword("newpass");
|
||||
userService.changePassword(id, dto);
|
||||
|
||||
verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword())));
|
||||
}
|
||||
|
||||
// ─── getGroupById ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -58,6 +58,19 @@ services:
|
||||
networks:
|
||||
- archive-net
|
||||
|
||||
# --- Mail catcher: Mailpit (dev only) ---
|
||||
# Catches all outgoing emails and displays them in a web UI.
|
||||
# Access the inbox at http://localhost:${PORT_MAILPIT_UI} after starting the stack.
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
container_name: archive-mailpit
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT_MAILPIT_UI:-8025}:8025" # Web UI
|
||||
- "${PORT_MAILPIT_SMTP:-1025}:1025" # SMTP
|
||||
networks:
|
||||
- archive-net
|
||||
|
||||
# --- Backend: Spring Boot ---
|
||||
backend:
|
||||
build:
|
||||
@@ -74,6 +87,8 @@ services:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
||||
@@ -83,6 +98,16 @@ services:
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
|
||||
S3_REGION: us-east-1
|
||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
|
||||
# Defaults to the local Mailpit catcher — override in .env for production SMTP
|
||||
MAIL_HOST: ${MAIL_HOST:-mailpit}
|
||||
MAIL_PORT: ${MAIL_PORT:-1025}
|
||||
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||
APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@familienarchiv.local}
|
||||
# Mailpit needs no auth or STARTTLS; production SMTP overrides these via .env
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-false}
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
||||
ports:
|
||||
- "${PORT_BACKEND}:8080"
|
||||
networks:
|
||||
|
||||
96
docs/mail.md
Normal file
96
docs/mail.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Mail configuration
|
||||
|
||||
Familienarchiv uses Spring Mail to send password reset emails. The mail sender is **optional** — if no SMTP host is configured, the feature degrades gracefully: a reset token is still created in the database, but no email is sent and a warning is logged.
|
||||
|
||||
## How it works in each environment
|
||||
|
||||
| Environment | Default behaviour |
|
||||
|---|---|
|
||||
| `docker-compose up` (dev) | Mailpit catches all emails — nothing leaves your machine |
|
||||
| CI | No mail host set — emails are silently skipped, tokens tested via the `/api/auth/reset-token-for-test` endpoint |
|
||||
| Production | Real SMTP server configured via environment variables |
|
||||
|
||||
---
|
||||
|
||||
## Development — Mailpit
|
||||
|
||||
[Mailpit](https://github.com/axllent/mailpit) is included in `docker-compose.yml` as a local mail catcher. It accepts SMTP connections from the backend and displays all caught emails in a web inbox. No credentials or external network access required.
|
||||
|
||||
**Start the stack as usual:**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**Open the inbox:**
|
||||
|
||||
```
|
||||
http://localhost:8025
|
||||
```
|
||||
|
||||
All password reset emails appear here. Copy the reset link from the email body and open it in your browser to complete the flow end-to-end locally.
|
||||
|
||||
**Ports (configurable in `.env`):**
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `PORT_MAILPIT_UI` | `8025` | Mailpit web inbox |
|
||||
| `PORT_MAILPIT_SMTP` | `1025` | SMTP port (used internally by the backend) |
|
||||
|
||||
---
|
||||
|
||||
## Production — real SMTP
|
||||
|
||||
To send real emails, set the following variables in your `.env` file (or as host environment variables). The `MAIL_HOST` variable is the switch — leaving it empty disables outgoing mail entirely.
|
||||
|
||||
```dotenv
|
||||
# Required
|
||||
APP_BASE_URL=https://your-domain.example.com # Base URL inserted into reset links
|
||||
MAIL_HOST=smtp.example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=your-smtp-user
|
||||
MAIL_PASSWORD=your-smtp-password
|
||||
|
||||
# Optional — adjust if your provider uses different settings
|
||||
MAIL_SMTP_AUTH=true # default: false (Mailpit needs false)
|
||||
MAIL_STARTTLS_ENABLE=true # default: false (Mailpit needs false)
|
||||
APP_MAIL_FROM=noreply@your-domain.example.com
|
||||
```
|
||||
|
||||
**Common provider settings:**
|
||||
|
||||
| Provider | Host | Port | Auth | STARTTLS |
|
||||
|---|---|---|---|---|
|
||||
| Gmail (App Password) | `smtp.gmail.com` | `587` | `true` | `true` |
|
||||
| Mailgun | `smtp.mailgun.org` | `587` | `true` | `true` |
|
||||
| Hetzner | `mail.your-server.de` | `587` | `true` | `true` |
|
||||
| Self-hosted Postfix | your server IP/hostname | `587` | `true` | `true` |
|
||||
|
||||
> **Gmail note:** You must use an [App Password](https://support.google.com/accounts/answer/185833), not your regular account password. 2-Step Verification must be enabled on the account.
|
||||
|
||||
---
|
||||
|
||||
## Environment variable reference
|
||||
|
||||
All variables have safe defaults so the app starts without any mail configuration.
|
||||
|
||||
| Variable | Default (docker-compose) | Description |
|
||||
|---|---|---|
|
||||
| `MAIL_HOST` | `mailpit` | SMTP hostname. Empty string disables mail entirely. |
|
||||
| `MAIL_PORT` | `1025` | SMTP port. |
|
||||
| `MAIL_USERNAME` | *(empty)* | SMTP username. Leave empty if your server needs no auth. |
|
||||
| `MAIL_PASSWORD` | *(empty)* | SMTP password. |
|
||||
| `MAIL_SMTP_AUTH` | `false` | Enable SMTP authentication (`true` for real servers). |
|
||||
| `MAIL_STARTTLS_ENABLE` | `false` | Enable STARTTLS (`true` for real servers on port 587). |
|
||||
| `APP_MAIL_FROM` | `noreply@familienarchiv.local` | The `From:` address on outgoing emails. |
|
||||
| `APP_BASE_URL` | `http://localhost:3000` | Base URL prepended to password reset links. |
|
||||
|
||||
---
|
||||
|
||||
## Disabling mail entirely
|
||||
|
||||
Set `MAIL_HOST` to an empty string. Spring Boot will not create a mail sender bean and no emails will be sent. Password reset tokens are still written to the database — useful if you want to test the reset flow via the API directly.
|
||||
|
||||
```dotenv
|
||||
MAIL_HOST=
|
||||
```
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -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*
|
||||
|
||||
1
frontend/.husky/pre-commit
Normal file
1
frontend/.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npm test
|
||||
@@ -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/
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
|
||||
25
frontend/e2e/.auth/user.json
Normal file
25
frontend/e2e/.auth/user.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "PARAGLIDE_LOCALE",
|
||||
"value": "de",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1808565334.192108,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "auth_token",
|
||||
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1774091734.449243,
|
||||
"httpOnly": true,
|
||||
"secure": false,
|
||||
"sameSite": "Strict"
|
||||
}
|
||||
],
|
||||
"origins": []
|
||||
}
|
||||
218
frontend/e2e/admin.spec.ts
Normal file
218
frontend/e2e/admin.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { test, expect, type Browser } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Admin panel E2E tests.
|
||||
*
|
||||
* Reads top-to-bottom as a complete admin journey:
|
||||
* 1. Admin opens the dashboard and sees all three management tabs.
|
||||
* 2. Admin creates a group for read-only access.
|
||||
* 3. Admin creates a new user in that group.
|
||||
* 4. Admin edits the user's profile.
|
||||
* 5. Admin resets the user's password without knowing their current password.
|
||||
* 6. The user can log in with the admin-set password.
|
||||
* 7. Admin deletes the user.
|
||||
* 8. Admin deletes the test group.
|
||||
* 9. Admin renames a tag and renames it back.
|
||||
*
|
||||
* Steps 2–8 form a self-contained lifecycle: everything created in this suite
|
||||
* is also deleted, leaving the database in its original state.
|
||||
*/
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin dashboard', () => {
|
||||
test('admin navigates to /admin and sees the three management tabs', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Benutzer', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Gruppen', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Schlagworte', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-dashboard.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin — group management', () => {
|
||||
test('admin creates a new group "E2E Leser" with READ_ALL permission', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Switch to the Groups tab
|
||||
await page.getByRole('button', { name: 'Gruppen', exact: true }).click();
|
||||
|
||||
await page.getByPlaceholder('Gruppenname (z.B. Editoren)').fill('E2E Leser');
|
||||
|
||||
// No permission checkboxes checked — READ_ALL is handled at application level
|
||||
// (a group with no permissions gets read-only access by default in the UI)
|
||||
|
||||
await page.getByRole('button', { name: /Erstellen/i }).click();
|
||||
|
||||
await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-group-created.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── User lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin — user lifecycle', () => {
|
||||
test('admin creates user "e2e-testuser" and they appear in the user list', async ({ page }) => {
|
||||
await page.goto('/admin/users/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.locator('input[name="username"]').fill('e2e-testuser');
|
||||
await page.locator('input[name="password"]').fill('InitPass123!');
|
||||
|
||||
// Assign to the group we just created
|
||||
const groupLabel = page.locator('label').filter({ hasText: 'E2E Leser' });
|
||||
if ((await groupLabel.count()) > 0) {
|
||||
await groupLabel.locator('input[type="checkbox"]').check();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /Erstellen/i }).click();
|
||||
|
||||
// Redirected back to /admin — user appears in the table
|
||||
await expect(page).toHaveURL('/admin');
|
||||
await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-user-created.png' });
|
||||
});
|
||||
|
||||
test('admin opens the edit page and updates the user first name', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Click the edit link for the test user
|
||||
const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' });
|
||||
await userRow.getByRole('link', { name: /Bearbeiten/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users\/.+/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Benutzer bearbeiten: e2e-testuser/i })
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('input[name="firstName"]').fill('E2E');
|
||||
await page.locator('input[name="lastName"]').fill('Testuser');
|
||||
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
|
||||
await expect(page.getByText('Änderungen gespeichert.')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-user-edited.png' });
|
||||
});
|
||||
|
||||
test('admin sets a new password without entering the current password', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' });
|
||||
await userRow.getByRole('link', { name: /Bearbeiten/i }).click();
|
||||
|
||||
// Password fields — no current password field on the admin edit form
|
||||
await page.locator('input[name="newPassword"]').fill('AdminSet456!');
|
||||
await page.locator('input[name="confirmPassword"]').fill('AdminSet456!');
|
||||
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
|
||||
await expect(page.getByText('Änderungen gespeichert.')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-user-password-reset.png' });
|
||||
});
|
||||
|
||||
test('the user can log in with the admin-set password', async ({ browser }) => {
|
||||
// Open a completely separate browser context — no shared session cookies
|
||||
const freshCtx = await (browser as Browser).newContext({
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const freshPage = await freshCtx.newPage();
|
||||
|
||||
await freshPage.goto('/login');
|
||||
await freshPage.getByLabel('Benutzername').fill('e2e-testuser');
|
||||
await freshPage.getByLabel('Passwort').fill('AdminSet456!');
|
||||
await freshPage.getByRole('button', { name: 'Anmelden' }).click();
|
||||
|
||||
await expect(freshPage).toHaveURL('/');
|
||||
await freshPage.screenshot({ path: 'test-results/e2e/admin-user-login-new-password.png' });
|
||||
|
||||
await freshCtx.close();
|
||||
});
|
||||
|
||||
test('admin deletes the test user and they disappear from the list', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' });
|
||||
|
||||
// The delete button triggers a window.confirm() dialog
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await userRow.getByTitle('Benutzer löschen').click();
|
||||
|
||||
await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).not.toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-user-deleted.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group cleanup ──────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin — group cleanup', () => {
|
||||
test('admin deletes the "E2E Leser" group', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: 'Gruppen' }).click();
|
||||
|
||||
const groupRow = page.locator('tr').filter({ hasText: 'E2E Leser' });
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await groupRow.getByTitle('Löschen').click();
|
||||
|
||||
await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).not.toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-group-deleted.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tag management ─────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin — tag management', () => {
|
||||
test('admin renames a tag and sees the change in the list', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: 'Schlagworte', exact: true }).click();
|
||||
// Wait for the tags list to render after the tab switch
|
||||
await page.waitForSelector('ul > li');
|
||||
|
||||
// Hover over the "Familie" row to reveal the opacity-0 action buttons
|
||||
const familieRow = page
|
||||
.locator('ul > li')
|
||||
.filter({ has: page.locator('span', { hasText: /^Familie$/ }) });
|
||||
await familieRow.hover();
|
||||
await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
|
||||
|
||||
// After clicking edit, {#if editingTagId} replaces the span with a form —
|
||||
// the familieRow filter no longer matches, so we find the input directly.
|
||||
await page.locator('input[name="name"]').fill('Familie (E2E)');
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
|
||||
await expect(page.getByText('Familie (E2E)')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' });
|
||||
});
|
||||
|
||||
test('admin renames it back to restore the original name', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: 'Schlagworte', exact: true }).click();
|
||||
await page.waitForSelector('ul > li');
|
||||
|
||||
const renamedRow = page
|
||||
.locator('ul > li')
|
||||
.filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) });
|
||||
await renamedRow.hover();
|
||||
await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
|
||||
|
||||
await page.locator('input[name="name"]').fill('Familie');
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
|
||||
await expect(page.getByText('Familie')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,11 @@ const authFile = path.join(__dirname, '.auth/user.json');
|
||||
* Logs in once and saves the session cookie so all E2E tests can reuse it.
|
||||
* Configure credentials via environment variables:
|
||||
* E2E_USERNAME (default: admin)
|
||||
* E2E_PASSWORD (default: admin)
|
||||
* E2E_PASSWORD (default: admin123)
|
||||
*/
|
||||
setup('authenticate', async ({ page }) => {
|
||||
const username = process.env.E2E_USERNAME ?? 'admin';
|
||||
const password = process.env.E2E_PASSWORD ?? 'admin';
|
||||
const password = process.env.E2E_PASSWORD ?? 'admin123';
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Benutzername').fill(username);
|
||||
|
||||
@@ -48,8 +48,27 @@ test.describe('Authentication', () => {
|
||||
await page.screenshot({ path: 'test-results/e2e/login-success.png' });
|
||||
});
|
||||
|
||||
test('login establishes a session that authenticates API calls', async ({ page }) => {
|
||||
// Guards against regressions where the session cookie is set but broken.
|
||||
// The profile page calls /api/users/me server-side — if auth works end-to-end,
|
||||
// it loads without redirecting to /login.
|
||||
await login(page);
|
||||
await page.goto('/profile');
|
||||
await expect(page).toHaveURL('/profile');
|
||||
await expect(page.getByRole('heading', { name: /Mein Profil/i })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/auth-session-valid.png' });
|
||||
});
|
||||
|
||||
test('logout clears the session and redirects to /login', async ({ page }) => {
|
||||
await login(page);
|
||||
// Wait for hydration before interacting with the nav — onclick handlers are
|
||||
// only wired up after SvelteKit finishes hydrating the page client-side.
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
// Logout is inside the user avatar dropdown — open it first.
|
||||
// Wait for the dropdown button to be visible before clicking Abmelden,
|
||||
// since the {#if userMenuOpen} block renders asynchronously in Svelte.
|
||||
await page.locator('button[aria-haspopup="true"]').click();
|
||||
await expect(page.getByRole('button', { name: 'Abmelden' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Abmelden' }).click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
// Confirm session is gone: navigating to / redirects back
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Document management E2E tests.
|
||||
@@ -80,6 +81,41 @@ test.describe('New document', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document creation', () => {
|
||||
test('user fills in a title and lands on the new document detail page', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByLabel('Titel').fill('E2E Testbrief');
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/document-create.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document editing', () => {
|
||||
test('user opens an existing document, changes the title, and sees the update', async ({
|
||||
page
|
||||
}) => {
|
||||
// Find the document created in the previous describe
|
||||
await page.goto('/?q=E2E+Testbrief');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
const docLink = page.getByRole('link', { name: 'E2E Testbrief' }).first();
|
||||
const href = await docLink.getAttribute('href');
|
||||
await page.goto(`${href}/edit`);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)');
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
await expect(page.getByText('E2E Testbrief (überarbeitet)')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/document-edit-save.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document edit', () => {
|
||||
test('renders the edit form with pre-filled data', async ({ page }) => {
|
||||
// Navigate to home, find first document, go to its edit page
|
||||
@@ -107,3 +143,78 @@ test.describe('Document edit', () => {
|
||||
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PDF Viewer ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
|
||||
test.describe('PDF viewer', () => {
|
||||
let pdfDocHref: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Create a document and upload the PDF fixture so later tests have a
|
||||
// real file attached. Runs once for the whole describe block.
|
||||
const ctx = await browser.newContext();
|
||||
const p = await ctx.newPage();
|
||||
|
||||
await p.goto('/documents/new');
|
||||
await p.waitForSelector('[data-hydrated]');
|
||||
await p.getByLabel('Titel').fill('E2E PDF Viewer Test');
|
||||
await p.getByRole('button', { name: /Speichern/i }).click();
|
||||
await p.waitForURL(/\/documents\/[^/]+$/);
|
||||
|
||||
// Upload the PDF on the edit page
|
||||
const href = p.url().replace(/\/$/, '');
|
||||
pdfDocHref = href;
|
||||
await p.goto(`${href}/edit`);
|
||||
await p.waitForSelector('[data-hydrated]');
|
||||
await p.locator('input[type="file"][name="file"]').setInputFiles(PDF_FIXTURE);
|
||||
await p.getByRole('button', { name: /Speichern/i }).click();
|
||||
await p.waitForURL(/\/documents\/[^/]+$/);
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(pdfDocHref);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// There must be NO iframe — we replaced it with PDF.js canvas rendering.
|
||||
await expect(page.locator('iframe')).not.toBeAttached();
|
||||
|
||||
// At least one canvas element must be visible (one per rendered page).
|
||||
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-canvas.png' });
|
||||
});
|
||||
|
||||
test('page navigation controls are visible', async ({ page }) => {
|
||||
await page.goto(pdfDocHref);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
await expect(page.getByRole('button', { name: /prev|previous|zurück|vorige/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /next|weiter|nächste/i })).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
|
||||
});
|
||||
|
||||
test('non-PDF attachment renders as an img element, not canvas', async ({ page }) => {
|
||||
// The seed document "Urlaubspostkarte Ostsee" has a .jpg original filename.
|
||||
// Navigate to it and confirm an <img> is used (no canvas, no iframe).
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.goto('/?q=Urlaubspostkarte');
|
||||
const link = page.getByRole('link', { name: /Urlaubspostkarte/i }).first();
|
||||
const href = await link.getAttribute('href');
|
||||
await page.goto(href!);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// No canvas — this is an image document
|
||||
await expect(page.locator('canvas')).not.toBeAttached();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
|
||||
});
|
||||
});
|
||||
|
||||
21
frontend/e2e/fixtures/minimal.pdf
Normal file
21
frontend/e2e/fixtures/minimal.pdf
Normal file
@@ -0,0 +1,21 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<</Type/Catalog/Pages 2 0 R>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Type/Pages/Kids[3 0 R]/Count 1>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000054 00000 n
|
||||
0000000105 00000 n
|
||||
trailer
|
||||
<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
170
|
||||
%%EOF
|
||||
@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test';
|
||||
export async function login(
|
||||
page: Page,
|
||||
username = process.env.E2E_USERNAME ?? 'admin',
|
||||
password = process.env.E2E_PASSWORD ?? 'admin'
|
||||
password = process.env.E2E_PASSWORD ?? 'admin123'
|
||||
) {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Benutzername').fill(username);
|
||||
|
||||
112
frontend/e2e/history.spec.ts
Normal file
112
frontend/e2e/history.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Document edit history E2E tests.
|
||||
* Creates its own test document (two versions) in beforeAll so these tests
|
||||
* are fully independent of any other spec file.
|
||||
*/
|
||||
|
||||
let docPath: string;
|
||||
|
||||
test.describe('Document history panel', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Create a fresh browser context that uses the stored auth session
|
||||
const context = await browser.newContext({
|
||||
storageState: path.join(__dirname, '.auth/user.json'),
|
||||
locale: 'de-DE'
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// 1. Create a new document
|
||||
await page.goto('/documents/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByLabel('Titel').fill('E2E History Test Dokument');
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
// Wait for redirect to the new document's UUID-based URL (not /documents/new)
|
||||
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||
docPath = new URL(page.url()).pathname;
|
||||
|
||||
// 2. Edit the document to create a second version
|
||||
await page.goto(`${docPath}/edit`);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)');
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('history section appears and shows two versions', async ({ page }) => {
|
||||
await page.goto(docPath);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
||||
await expect(historyToggle).toBeVisible();
|
||||
await historyToggle.click();
|
||||
|
||||
// Wait for versions to load (API call happens after panel opens)
|
||||
const versionItems = page.locator('[data-testid="history-version"]');
|
||||
await expect(versionItems).toHaveCount(2, { timeout: 10000 });
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/history-versions-list.png' });
|
||||
});
|
||||
|
||||
test('diff view highlights changed field after title edit', async ({ page }) => {
|
||||
await page.goto(docPath);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
||||
await historyToggle.click();
|
||||
|
||||
// Wait for versions to load, then click the second one (the edit)
|
||||
const versionItems = page.locator('[data-testid="history-version"]');
|
||||
await expect(versionItems.nth(1)).toBeVisible({ timeout: 10000 });
|
||||
await versionItems.nth(1).click();
|
||||
|
||||
const diffPanel = page.locator('[data-testid="history-diff"]');
|
||||
await expect(diffPanel).toBeVisible();
|
||||
await expect(diffPanel.getByText(/Titel/i)).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/history-diff-title.png' });
|
||||
});
|
||||
|
||||
test('compare mode lets user compare any two versions', async ({ page }) => {
|
||||
await page.goto(docPath);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
||||
await historyToggle.click();
|
||||
|
||||
// Wait for versions to load before the compare button appears
|
||||
await expect(page.locator('[data-testid="history-version"]').first()).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const compareBtn = page.getByRole('button', { name: /Vergleichen/i });
|
||||
await expect(compareBtn).toBeVisible();
|
||||
await compareBtn.click();
|
||||
|
||||
const selectA = page.getByLabel(/Version A/i);
|
||||
const selectB = page.getByLabel(/Version B/i);
|
||||
await expect(selectA).toBeVisible();
|
||||
await expect(selectB).toBeVisible();
|
||||
|
||||
// Select version 1 for A and version 2 for B
|
||||
await selectA.selectOption({ index: 1 });
|
||||
await selectB.selectOption({ index: 2 });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /Vergleichen/i })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
const diffPanel = page.locator('[data-testid="history-diff"]');
|
||||
await expect(diffPanel).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/history-compare-mode.png' });
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,24 @@ import { test, expect } from '@playwright/test';
|
||||
test.describe('Language selector', () => {
|
||||
test('shows DE, EN, ES buttons in the header', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: 'DE', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: 'EN', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: 'ES', exact: true })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('switching to EN translates the navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: 'Persons' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -21,15 +29,27 @@ test.describe('Language selector', () => {
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await page.goto('/persons');
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: 'Documents' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('switching back to DE restores German', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByRole('banner').getByRole('button', { name: 'EN', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Documents' })
|
||||
).toBeVisible();
|
||||
await page.getByRole('banner').getByRole('button', { name: 'DE', exact: true }).click();
|
||||
await expect(page.getByRole('navigation').getByRole('link', { name: 'Dokumente' })).toBeVisible();
|
||||
// In headless Chromium, cookie deletion via document.cookie can be unreliable.
|
||||
// Delete the PARAGLIDE_LOCALE cookie directly so the next navigation defaults to DE.
|
||||
await page.context().clearCookies({ name: 'PARAGLIDE_LOCALE' });
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('link', { name: 'Dokumente' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('active language button is visually highlighted', async ({ page }) => {
|
||||
|
||||
113
frontend/e2e/password-reset.spec.ts
Normal file
113
frontend/e2e/password-reset.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Password-reset E2E tests.
|
||||
*
|
||||
* These tests run WITHOUT a stored session because they test unauthenticated flows.
|
||||
*
|
||||
* They rely on the "e2e" Spring profile being active in CI (see playwright.config.ts /
|
||||
* docker-compose.e2e.yml). The profile exposes GET /api/auth/reset-token-for-test?email=
|
||||
* so we can retrieve the generated token without a real mail server.
|
||||
*/
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// The backend is accessible directly for E2E helper calls (no SvelteKit proxy needed).
|
||||
const BACKEND_URL = process.env.E2E_BACKEND_URL ?? 'http://localhost:8080';
|
||||
|
||||
async function getResetToken(email: string): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${BACKEND_URL}/api/auth/reset-token-for-test?email=${encodeURIComponent(email)}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`Could not retrieve reset token for ${email}: ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
test.describe('Password reset', () => {
|
||||
test('forgot-password page is accessible without login', async ({ page }) => {
|
||||
await page.goto('/forgot-password');
|
||||
await expect(page).toHaveURL('/forgot-password');
|
||||
await expect(page.getByRole('heading', { name: /Passwort vergessen/i })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-form.png' });
|
||||
});
|
||||
|
||||
test('forgot-password shows success banner for any email (prevents user enumeration)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/forgot-password');
|
||||
await page.getByLabel(/E-Mail/i).fill('nonexistent@example.com');
|
||||
await page.getByRole('button', { name: /Link anfordern/i }).click();
|
||||
// Always shows success — never reveals whether the email exists
|
||||
await expect(page.locator('.bg-green-50')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-success-banner.png' });
|
||||
});
|
||||
|
||||
test('full password reset flow', async ({ page }) => {
|
||||
const testEmail = process.env.E2E_EMAIL ?? 'admin@familyarchive.local';
|
||||
const originalPassword = process.env.E2E_PASSWORD ?? 'admin123';
|
||||
const newPassword = 'NewP@ssw0rd_E2E!';
|
||||
|
||||
// 1. Request reset
|
||||
await page.goto('/forgot-password');
|
||||
await page.getByLabel(/E-Mail/i).fill(testEmail);
|
||||
await page.getByRole('button', { name: /Link anfordern/i }).click();
|
||||
await expect(page.locator('.bg-green-50')).toBeVisible();
|
||||
|
||||
// 2. Fetch the token via the test helper endpoint
|
||||
const token = await getResetToken(testEmail);
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
|
||||
// 3. Open the reset-password page with the token
|
||||
await page.goto(`/reset-password?token=${token}`);
|
||||
await expect(page.getByRole('heading', { name: /Neues Passwort/i })).toBeVisible();
|
||||
await page.getByLabel(/^Neues Passwort$/i).fill(newPassword);
|
||||
await page.getByLabel(/Passwort bestätigen/i).fill(newPassword);
|
||||
await page.getByRole('button', { name: /Passwort speichern/i }).click();
|
||||
|
||||
// 4. Success banner — then navigate to login
|
||||
await expect(page.locator('.bg-green-50')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-changed.png' });
|
||||
await page.getByRole('link', { name: /Zurück zum Login/i }).click();
|
||||
|
||||
// 5. Log in with new password
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
|
||||
await page.getByLabel('Passwort').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await expect(page).toHaveURL('/');
|
||||
|
||||
// 6. Restore original password via profile page
|
||||
await page.goto('/profile');
|
||||
await page.locator('input[name="currentPassword"]').fill(newPassword);
|
||||
await page.locator('input[name="newPassword"]').fill(originalPassword);
|
||||
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
|
||||
// Profile page has two "Speichern" buttons — the password form is the last one
|
||||
await page.locator('button[type="submit"]').last().click();
|
||||
// After changing password, auth_token is stale → redirect to login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
// 7. Log back in with original password to confirm restore worked
|
||||
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
|
||||
await page.getByLabel('Passwort').fill(originalPassword);
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await expect(page).toHaveURL('/');
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-restored.png' });
|
||||
});
|
||||
|
||||
test('reset-password page shows error for invalid token', async ({ page }) => {
|
||||
await page.goto('/reset-password?token=invalidtoken000');
|
||||
await page.getByLabel(/^Neues Passwort$/i).fill('somepassword');
|
||||
await page.getByLabel(/Passwort bestätigen/i).fill('somepassword');
|
||||
await page.getByRole('button', { name: /Passwort speichern/i }).click();
|
||||
await expect(page.locator('.text-red-600')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-invalid-token.png' });
|
||||
});
|
||||
|
||||
test('reset-password page shows mismatch error when passwords differ', async ({ page }) => {
|
||||
await page.goto('/reset-password?token=anytoken');
|
||||
await page.getByLabel(/^Neues Passwort$/i).fill('password1');
|
||||
await page.getByLabel(/Passwort bestätigen/i).fill('password2');
|
||||
await page.getByRole('button', { name: /Passwort speichern/i }).click();
|
||||
await expect(page.locator('.text-red-600')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/password-reset-mismatch.png' });
|
||||
});
|
||||
});
|
||||
87
frontend/e2e/permissions.spec.ts
Normal file
87
frontend/e2e/permissions.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
/**
|
||||
* Permission E2E tests.
|
||||
*
|
||||
* Two describe blocks form the full story:
|
||||
* 1. Admin user — can see all write controls.
|
||||
* 2. Read-only user ("reader", seeded in DataInitializer with READ_ALL only) —
|
||||
* can browse content but sees no write controls anywhere.
|
||||
*/
|
||||
|
||||
test.describe('Write permissions — admin user', () => {
|
||||
test('admin user sees Neues Dokument link on home page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('link', { name: /Neues Dokument/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin user sees Neue Person link on persons page', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin user can navigate to /persons/new', async ({ page }) => {
|
||||
await page.goto('/persons/new');
|
||||
await expect(page).toHaveURL('/persons/new');
|
||||
await expect(page.getByLabel('Vorname')).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin user can navigate to /documents/new', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
await expect(page).toHaveURL('/documents/new');
|
||||
});
|
||||
|
||||
test('admin user sees edit button on person detail page', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||
await firstPerson.click();
|
||||
await expect(page.getByRole('button', { name: /Bearbeiten/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Read-only user journey ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The "reader" user is seeded by DataInitializer (e2e profile) with READ_ALL only.
|
||||
// They can browse documents and persons but must not see any mutation controls.
|
||||
|
||||
test.describe('Read-only user — no write controls visible', () => {
|
||||
// Fresh session — no shared admin cookies
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'reader', 'reader123');
|
||||
});
|
||||
|
||||
test('read-only user is redirected to home after login', async ({ page }) => {
|
||||
await expect(page).toHaveURL('/');
|
||||
await page.screenshot({ path: 'test-results/e2e/permissions-reader-home.png' });
|
||||
});
|
||||
|
||||
test('home page does not show the "Neues Dokument" link', async ({ page }) => {
|
||||
await expect(page.getByRole('link', { name: /Neues Dokument/i })).not.toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc.png' });
|
||||
});
|
||||
|
||||
test('persons page does not show the "Neue Person" link', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
await expect(page.getByRole('link', { name: /Neue Person/i })).not.toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-person.png' });
|
||||
});
|
||||
|
||||
test('person detail page does not show the edit button', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||
await firstPerson.click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await expect(page.getByRole('button', { name: /Bearbeiten/i })).not.toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-edit.png' });
|
||||
});
|
||||
|
||||
test('navigating directly to /documents/new redirects away', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
// Read-only user should not be able to access the new document form
|
||||
await expect(page).not.toHaveURL('/documents/new');
|
||||
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc-direct.png' });
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,29 @@ test.describe('Person detail', () => {
|
||||
await expect(page.getByLabel('Vorname')).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('birth and death year fields appear in edit mode and save correctly', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||
await firstPerson.click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const editBtn = page.getByRole('button', { name: /Bearbeiten/i });
|
||||
await editBtn.click();
|
||||
|
||||
await expect(page.getByLabel(/Geburtsjahr/i)).toBeVisible();
|
||||
await expect(page.getByLabel(/Todesjahr/i)).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Geburtsjahr/i).fill('1890');
|
||||
await page.getByLabel(/Todesjahr/i).fill('1965');
|
||||
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
|
||||
// After saving, the years should be shown in view mode
|
||||
await expect(page.getByText('* 1890')).toBeVisible();
|
||||
await expect(page.getByText('† 1965')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-birth-death-years.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('New person', () => {
|
||||
@@ -72,35 +95,110 @@ test.describe('New person', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Person creation', () => {
|
||||
test('user fills in first and last name and lands on the new person detail page', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/persons/new');
|
||||
await page.getByLabel('Vorname').fill('E2E');
|
||||
await page.getByLabel('Nachname').fill('Testperson');
|
||||
await page.getByRole('button', { name: /Erstellen/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/persons\/[^/]+$/);
|
||||
await expect(page.getByRole('heading', { name: 'E2E Testperson' })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-create.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Person detail — sort toggle', () => {
|
||||
test('sort toggle changes the button label when person has documents', async ({ page }) => {
|
||||
test('each section has its own sort toggle that works independently', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPerson.click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const sortBtn = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i });
|
||||
if (await sortBtn.isVisible()) {
|
||||
await expect(sortBtn).toContainText('Neueste zuerst');
|
||||
await sortBtn.click();
|
||||
await expect(sortBtn).toContainText('Älteste zuerst');
|
||||
await sortBtn.click();
|
||||
await expect(sortBtn).toContainText('Neueste zuerst');
|
||||
await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' });
|
||||
// Find sort buttons — there may be 0, 1 or 2 depending on whether sections have >1 doc
|
||||
const sortBtns = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i });
|
||||
const btnCount = await sortBtns.count();
|
||||
|
||||
if (btnCount >= 1) {
|
||||
const firstBtn = sortBtns.first();
|
||||
await expect(firstBtn).toContainText('Neueste zuerst');
|
||||
await firstBtn.click();
|
||||
await expect(firstBtn).toContainText('Älteste zuerst');
|
||||
await firstBtn.click();
|
||||
await expect(firstBtn).toContainText('Neueste zuerst');
|
||||
}
|
||||
|
||||
if (btnCount >= 2) {
|
||||
// Second sort button toggles independently
|
||||
const secondBtn = sortBtns.nth(1);
|
||||
await expect(secondBtn).toContainText('Neueste zuerst');
|
||||
await secondBtn.click();
|
||||
await expect(secondBtn).toContainText('Älteste zuerst');
|
||||
// First button should be unaffected
|
||||
await expect(sortBtns.first()).toContainText('Neueste zuerst');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Person detail — sent and received documents', () => {
|
||||
test('shows both sent and received document sections', async ({ page }) => {
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||||
await firstPerson.click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Gesendete Dokumente/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /Empfangene Dokumente/i })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-sent-received.png' });
|
||||
});
|
||||
|
||||
test('shows year range next to document count when documents have dates', async ({ page }) => {
|
||||
// Navigate to the first person who has documents with dates
|
||||
await page.goto('/persons');
|
||||
const personLinks = page.locator('a[href^="/persons/"]:not([href="/persons/new"])');
|
||||
const count = await personLinks.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await page.goto('/persons');
|
||||
await personLinks.nth(i).click();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Check if either section heading has a year range (4 digits)
|
||||
const sentHeading = page.getByRole('heading', { name: /Gesendete Dokumente/i }).locator('..');
|
||||
const hasYearRange = await sentHeading.locator('span').filter({ hasText: /\d{4}/ }).count();
|
||||
if (hasYearRange > 0) {
|
||||
await expect(
|
||||
sentHeading.locator('span').filter({ hasText: /\d{4}/ }).first()
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/person-year-range.png' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If no person has dated documents, the test is a no-op (year range is optional)
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Person detail — conversations link', () => {
|
||||
test('has a conversations link that pre-fills the person', async ({ page }) => {
|
||||
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/"]').first();
|
||||
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();
|
||||
const convLink = page.getByRole('link', { name: /Konversationen/i });
|
||||
await expect(convLink).toBeVisible();
|
||||
await expect(convLink).toHaveAttribute('href', `/conversations?senderId=${personId}`);
|
||||
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=.+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,3 +226,87 @@ test.describe('Conversations', () => {
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Conversations — enhancements', () => {
|
||||
// Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
|
||||
// Navigate directly by URL so the test doesn't rely on typeahead interaction
|
||||
async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
|
||||
// Resolve person IDs from the persons list
|
||||
await page.goto('/persons');
|
||||
const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
|
||||
const hansHref = await hansLink.getAttribute('href');
|
||||
const hansId = hansHref!.split('/').pop()!;
|
||||
|
||||
const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
|
||||
const annaHref = await annaLink.getAttribute('href');
|
||||
const annaId = annaHref!.split('/').pop()!;
|
||||
|
||||
await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
|
||||
await page.waitForURL(/senderId=/);
|
||||
}
|
||||
|
||||
test('shows document count and year range summary when both persons are selected', async ({
|
||||
page
|
||||
}) => {
|
||||
await loadHansAnnaConversation(page);
|
||||
// Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 1923–1965
|
||||
await expect(page.getByTestId('conv-summary')).toContainText('2');
|
||||
await expect(page.getByTestId('conv-summary')).toContainText('1923');
|
||||
await expect(page.getByTestId('conv-summary')).toContainText('1965');
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
|
||||
});
|
||||
|
||||
test('shows year dividers between documents from different years', async ({ page }) => {
|
||||
await loadHansAnnaConversation(page);
|
||||
// Expect at least two year dividers (1923 and 1965)
|
||||
await expect(page.getByTestId('year-divider').first()).toBeVisible();
|
||||
const dividers = page.getByTestId('year-divider');
|
||||
const texts = await dividers.allTextContents();
|
||||
expect(texts.some((t) => t.includes('1923'))).toBe(true);
|
||||
expect(texts.some((t) => t.includes('1965'))).toBe(true);
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
|
||||
});
|
||||
|
||||
test('swap button switches sender and receiver and reloads', async ({ page }) => {
|
||||
await loadHansAnnaConversation(page);
|
||||
const url = new URL(page.url());
|
||||
const originalSenderId = url.searchParams.get('senderId')!;
|
||||
const originalReceiverId = url.searchParams.get('receiverId')!;
|
||||
|
||||
await page.getByTestId('conv-swap-btn').click();
|
||||
// Wait for the URL to reflect the swapped IDs (not just any URL with senderId=)
|
||||
await page.waitForURL(
|
||||
(url) => new URL(url).searchParams.get('senderId') === originalReceiverId
|
||||
);
|
||||
|
||||
const swappedUrl = new URL(page.url());
|
||||
expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId);
|
||||
expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId);
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' });
|
||||
});
|
||||
|
||||
test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({
|
||||
page
|
||||
}) => {
|
||||
await loadHansAnnaConversation(page);
|
||||
const url = new URL(page.url());
|
||||
const senderId = url.searchParams.get('senderId')!;
|
||||
const receiverId = url.searchParams.get('receiverId')!;
|
||||
|
||||
const link = page.getByTestId('conv-new-doc-link');
|
||||
await expect(link).toBeVisible();
|
||||
const href = await link.getAttribute('href');
|
||||
expect(href).toContain(`senderId=${senderId}`);
|
||||
expect(href).toContain(`receiverId=${receiverId}`);
|
||||
await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' });
|
||||
});
|
||||
|
||||
test('does not show swap button or new document link when only one person is selected', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/conversations');
|
||||
await page.waitForURL('/conversations');
|
||||
await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible();
|
||||
await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
106
frontend/e2e/profile.spec.ts
Normal file
106
frontend/e2e/profile.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Profile page E2E tests.
|
||||
*
|
||||
* Reads top-to-bottom as a single user journey:
|
||||
* the logged-in admin opens their profile, updates their display name,
|
||||
* tries a wrong password (sees an error), then successfully changes their
|
||||
* password and logs back in with the new one.
|
||||
*
|
||||
* The password change test restores the original password at the end so the
|
||||
* shared session remains valid for all subsequent test files.
|
||||
*/
|
||||
|
||||
test.describe('Profile page', () => {
|
||||
test('user opens their profile and sees the personal data and password sections', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/profile');
|
||||
await expect(page.getByRole('heading', { name: /Mein Profil/i })).toBeVisible();
|
||||
await expect(page.getByText('Persönliche Daten')).toBeVisible();
|
||||
await expect(page.getByText('Passwort ändern')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/profile-view.png' });
|
||||
});
|
||||
|
||||
test('user saves updated first and last name and sees confirmation', async ({ page }) => {
|
||||
await page.goto('/profile');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.locator('input[name="firstName"]').fill('E2E');
|
||||
await page.locator('input[name="lastName"]').fill('Admin');
|
||||
|
||||
// Two "Speichern" buttons exist — the first belongs to the profile form
|
||||
await page
|
||||
.locator('form[action*="updateProfile"]')
|
||||
.getByRole('button', { name: /Speichern/i })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText('Gespeichert.')).toBeVisible();
|
||||
// Nav avatar shows the new initials derived from firstName + lastName
|
||||
await expect(page.locator('button[aria-haspopup="true"]')).toContainText('EA');
|
||||
await page.screenshot({ path: 'test-results/e2e/profile-save.png' });
|
||||
});
|
||||
|
||||
test('shows an error when the current password is wrong', async ({ page }) => {
|
||||
await page.goto('/profile');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.locator('input[name="currentPassword"]').fill('definitely-wrong');
|
||||
await page.locator('input[name="newPassword"]').fill('NewPass123!');
|
||||
await page.locator('input[name="confirmPassword"]').fill('NewPass123!');
|
||||
|
||||
await page
|
||||
.locator('form[action*="changePassword"]')
|
||||
.getByRole('button', { name: /Speichern/i })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText('Das aktuelle Passwort ist falsch.')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/profile-wrong-password.png' });
|
||||
});
|
||||
|
||||
test('user changes their password and can log in with the new one', async ({ page }) => {
|
||||
await page.goto('/profile');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// ── Step 1: change to a temporary password ─────────────────────────────
|
||||
await page.locator('input[name="currentPassword"]').fill('admin123');
|
||||
await page.locator('input[name="newPassword"]').fill('TempAdmin456!');
|
||||
await page.locator('input[name="confirmPassword"]').fill('TempAdmin456!');
|
||||
await page
|
||||
.locator('form[action*="changePassword"]')
|
||||
.getByRole('button', { name: /Speichern/i })
|
||||
.click();
|
||||
|
||||
// After the password changes, the auth_token cookie still carries the old
|
||||
// credentials. use:enhance re-runs the page's load function, which calls
|
||||
// the backend with the stale Basic Auth header → 401 → redirect to /login.
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
// ── Step 2: log in with the new password ───────────────────────────────
|
||||
await page.getByLabel('Benutzername').fill('admin');
|
||||
await page.getByLabel('Passwort').fill('TempAdmin456!');
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await expect(page).toHaveURL('/');
|
||||
await page.screenshot({ path: 'test-results/e2e/profile-password-changed.png' });
|
||||
|
||||
// ── Step 3: restore the original password so subsequent tests still work ─
|
||||
await page.goto('/profile');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.locator('input[name="currentPassword"]').fill('TempAdmin456!');
|
||||
await page.locator('input[name="newPassword"]').fill('admin123');
|
||||
await page.locator('input[name="confirmPassword"]').fill('admin123');
|
||||
await page
|
||||
.locator('form[action*="changePassword"]')
|
||||
.getByRole('button', { name: /Speichern/i })
|
||||
.click();
|
||||
// Redirected to /login again after credential rotation
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
// ── Step 4: log back in with the restored password ─────────────────────
|
||||
await page.getByLabel('Benutzername').fill('admin');
|
||||
await page.getByLabel('Passwort').fill('admin123');
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||
@@ -11,13 +10,11 @@
|
||||
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
|
||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
|
||||
"nav_documents": "Dokumente",
|
||||
"nav_persons": "Personen",
|
||||
"nav_conversations": "Konversationen",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Abmelden",
|
||||
|
||||
"btn_save": "Speichern",
|
||||
"btn_cancel": "Abbrechen",
|
||||
"btn_edit": "Bearbeiten",
|
||||
@@ -26,7 +23,6 @@
|
||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||
"btn_back": "Zurück",
|
||||
"btn_back_to_document": "Zurück zum Dokument",
|
||||
|
||||
"form_label_first_name": "Vorname",
|
||||
"form_label_last_name": "Nachname",
|
||||
"form_label_alias": "Rufname / Alias",
|
||||
@@ -47,12 +43,10 @@
|
||||
"form_label_archive_location": "Aufbewahrungsort",
|
||||
"form_placeholder_archive_location": "z.B. Schrank 3, Mappe B",
|
||||
"form_helper_archive_location": "Wo befindet sich das Originaldokument?",
|
||||
|
||||
"login_heading": "Anmelden",
|
||||
"login_label_username": "Benutzername",
|
||||
"login_label_password": "Passwort",
|
||||
"login_btn_submit": "Anmelden",
|
||||
|
||||
"docs_search_placeholder": "Suche in Titel, Inhalt, Ort...",
|
||||
"docs_btn_filter": "Filter",
|
||||
"docs_btn_reset_title": "Filter zurücksetzen",
|
||||
@@ -68,7 +62,6 @@
|
||||
"docs_list_from": "Von",
|
||||
"docs_list_to": "An",
|
||||
"docs_list_unknown": "Unbekannt",
|
||||
|
||||
"doc_section_who_when": "Wer & Wann",
|
||||
"doc_section_description": "Beschreibung",
|
||||
"doc_section_file": "Datei",
|
||||
@@ -79,7 +72,6 @@
|
||||
"doc_current_file_label": "Aktuelle Datei:",
|
||||
"doc_new_heading": "Neues Dokument",
|
||||
"doc_edit_heading": "Bearbeiten",
|
||||
|
||||
"doc_section_details": "Details",
|
||||
"doc_label_document_date": "Dokumentendatum",
|
||||
"doc_label_creation_location": "Erstellungsort",
|
||||
@@ -92,17 +84,14 @@
|
||||
"doc_loading": "Lade Dokument...",
|
||||
"doc_download_link": "Direkter Download versuchen",
|
||||
"doc_no_scan": "Kein Scan vorhanden",
|
||||
|
||||
"persons_heading": "Personenverzeichnis",
|
||||
"persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.",
|
||||
"persons_btn_new": "Neue Person",
|
||||
"persons_search_placeholder": "Namen suchen...",
|
||||
"persons_empty_heading": "Keine Personen gefunden.",
|
||||
"persons_empty_text": "Versuchen Sie einen anderen Suchbegriff.",
|
||||
|
||||
"persons_new_heading": "Neue Person",
|
||||
"persons_section_details": "Angaben zur Person",
|
||||
|
||||
"person_edit_heading": "Person bearbeiten",
|
||||
"person_label_full_name": "Voller Name",
|
||||
"person_merge_heading": "Person zusammenführen",
|
||||
@@ -111,9 +100,21 @@
|
||||
"person_btn_merge": "Zusammenführen",
|
||||
"person_btn_merge_confirm": "Ja, zusammenführen",
|
||||
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
|
||||
"person_label_notes": "Notizen",
|
||||
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
||||
"person_label_birth_year": "Geburtsjahr",
|
||||
"person_label_death_year": "Todesjahr",
|
||||
"person_placeholder_year": "z.B. 1923",
|
||||
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
||||
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
||||
"person_docs_heading": "Gesendete Dokumente",
|
||||
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
||||
|
||||
"person_received_docs_heading": "Empfangene Dokumente",
|
||||
"person_no_received_docs": "Diese Person ist noch nicht als Empfänger verknüpft.",
|
||||
"person_role_sender": "Gesendet",
|
||||
"person_role_receiver": "Empfangen",
|
||||
"person_co_correspondents_heading": "Häufige Korrespondenten",
|
||||
"person_show_more": "+ {count} weitere anzeigen",
|
||||
"conv_heading": "Konversationen",
|
||||
"conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.",
|
||||
"conv_label_person_a": "Person A (Absender)",
|
||||
@@ -127,7 +128,9 @@
|
||||
"conv_empty_text": "Die Korrespondenz wird hier angezeigt.",
|
||||
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
||||
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
||||
|
||||
"conv_swap_btn": "Personen tauschen",
|
||||
"conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}",
|
||||
"conv_new_doc_link": "Neues Dokument in dieser Korrespondenz",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Benutzer",
|
||||
"admin_tab_groups": "Gruppen",
|
||||
@@ -156,7 +159,14 @@
|
||||
"admin_section_new_group": "Neue Gruppe anlegen",
|
||||
"admin_group_name_placeholder": "Gruppenname (z.B. Editoren)",
|
||||
"admin_user_delete_confirm": "Benutzer {username} wirklich löschen?",
|
||||
|
||||
"admin_btn_new_user": "Neuer Benutzer",
|
||||
"admin_user_new_heading": "Neuen Benutzer anlegen",
|
||||
"admin_user_edit_heading": "Benutzer bearbeiten: {username}",
|
||||
"admin_user_created": "Benutzer wurde erstellt.",
|
||||
"admin_user_updated": "Änderungen gespeichert.",
|
||||
"admin_col_full_name": "Name",
|
||||
"admin_label_new_password_optional": "Neues Passwort (optional)",
|
||||
"admin_label_initial_password": "Passwort",
|
||||
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
|
||||
"doc_download_title": "Herunterladen",
|
||||
"doc_tag_filter_title": "Nach {name} filtern",
|
||||
@@ -164,9 +174,7 @@
|
||||
"doc_preview_iframe_title": "Dokumentvorschau",
|
||||
"doc_image_alt": "Original-Scan",
|
||||
"doc_no_date": "Kein Datum",
|
||||
|
||||
"person_merge_will_be_deleted": "wird gelöscht.",
|
||||
|
||||
"comp_typeahead_placeholder": "Namen tippen...",
|
||||
"comp_typeahead_loading": "Suche...",
|
||||
"comp_multiselect_placeholder": "Namen tippen...",
|
||||
@@ -175,5 +183,62 @@
|
||||
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
|
||||
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
|
||||
"comp_taginput_remove": "Schlagwort entfernen",
|
||||
"comp_taginput_create_hint": "Enter drücken um Schlagwort zu erstellen."
|
||||
"comp_taginput_create_hint": "Enter drücken um Schlagwort zu erstellen.",
|
||||
"error_email_already_in_use": "Diese E-Mail-Adresse wird bereits von einem anderen Konto verwendet.",
|
||||
"error_wrong_current_password": "Das aktuelle Passwort ist falsch.",
|
||||
"nav_profile": "Profil",
|
||||
"profile_heading": "Mein Profil",
|
||||
"profile_section_personal": "Persönliche Daten",
|
||||
"profile_label_first_name": "Vorname",
|
||||
"profile_label_last_name": "Nachname",
|
||||
"profile_label_birth_date": "Geburtsdatum",
|
||||
"profile_label_email": "E-Mail-Adresse",
|
||||
"profile_label_contact": "Kontaktdaten",
|
||||
"profile_contact_placeholder": "Telefon, Adresse oder sonstige Hinweise...",
|
||||
"profile_section_password": "Passwort ändern",
|
||||
"profile_label_current_password": "Aktuelles Passwort",
|
||||
"profile_label_new_password": "Neues Passwort",
|
||||
"profile_label_new_password_confirm": "Neues Passwort (Wiederholung)",
|
||||
"profile_password_mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
||||
"profile_saved": "Gespeichert.",
|
||||
"profile_password_changed": "Passwort erfolgreich geändert.",
|
||||
"user_profile_heading": "Profil von",
|
||||
"error_invalid_reset_token": "Der Link ist ungültig oder abgelaufen.",
|
||||
"forgot_password_heading": "Passwort vergessen",
|
||||
"forgot_password_email_label": "E-Mail-Adresse",
|
||||
"forgot_password_submit": "Link anfordern",
|
||||
"forgot_password_success": "Falls ein Konto mit dieser E-Mail-Adresse existiert, erhalten Sie in Kürze eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.",
|
||||
"forgot_password_back_to_login": "Zurück zum Login",
|
||||
"reset_password_heading": "Neues Passwort festlegen",
|
||||
"reset_password_label": "Neues Passwort",
|
||||
"reset_password_confirm_label": "Passwort bestätigen",
|
||||
"reset_password_submit": "Passwort speichern",
|
||||
"reset_password_mismatch": "Die Passwörter stimmen nicht überein.",
|
||||
"reset_password_success": "Ihr Passwort wurde erfolgreich geändert. Sie können sich jetzt anmelden.",
|
||||
"login_forgot_password": "Passwort vergessen?",
|
||||
"history_section_title": "Verlauf",
|
||||
"history_loading": "Lade Verlauf…",
|
||||
"history_empty": "Noch keine Versionen vorhanden.",
|
||||
"history_version_label": "Version",
|
||||
"history_compare_mode": "Vergleichen",
|
||||
"history_compare_select_a": "Version A",
|
||||
"history_compare_select_b": "Version B",
|
||||
"history_compare_apply": "Vergleichen",
|
||||
"history_diff_no_changes": "Keine Änderungen zwischen diesen Versionen.",
|
||||
"history_field_title": "Titel",
|
||||
"history_field_document_date": "Datum",
|
||||
"history_field_location": "Ort",
|
||||
"history_field_document_location": "Archivstandort",
|
||||
"history_field_transcription": "Transkription",
|
||||
"history_field_summary": "Zusammenfassung",
|
||||
"history_field_sender": "Absender",
|
||||
"history_field_receivers": "Empfänger",
|
||||
"history_field_tags": "Schlagworte",
|
||||
"admin_tab_system": "System",
|
||||
"admin_system_backfill_heading": "Verlaufsdaten auffüllen",
|
||||
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
|
||||
"admin_system_backfill_btn": "Jetzt auffüllen",
|
||||
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
||||
"comp_expandable_show_more": "Mehr anzeigen",
|
||||
"comp_expandable_show_less": "Weniger anzeigen"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"error_document_not_found": "Document not found.",
|
||||
"error_document_no_file": "No file is associated with this document.",
|
||||
"error_file_not_found": "The file could not be found in storage.",
|
||||
@@ -11,13 +10,11 @@
|
||||
"error_forbidden": "You do not have permission for this action.",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
|
||||
"nav_documents": "Documents",
|
||||
"nav_persons": "Persons",
|
||||
"nav_conversations": "Conversations",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Sign out",
|
||||
|
||||
"btn_save": "Save",
|
||||
"btn_cancel": "Cancel",
|
||||
"btn_edit": "Edit",
|
||||
@@ -26,7 +23,6 @@
|
||||
"btn_back_to_overview": "Back to overview",
|
||||
"btn_back": "Back",
|
||||
"btn_back_to_document": "Back to document",
|
||||
|
||||
"form_label_first_name": "First name",
|
||||
"form_label_last_name": "Last name",
|
||||
"form_label_alias": "Nickname / Alias",
|
||||
@@ -47,12 +43,10 @@
|
||||
"form_label_archive_location": "Storage location",
|
||||
"form_placeholder_archive_location": "e.g. Cabinet 3, Folder B",
|
||||
"form_helper_archive_location": "Where is the original document stored?",
|
||||
|
||||
"login_heading": "Sign in",
|
||||
"login_label_username": "Username",
|
||||
"login_label_password": "Password",
|
||||
"login_btn_submit": "Sign in",
|
||||
|
||||
"docs_search_placeholder": "Search in title, content, location...",
|
||||
"docs_btn_filter": "Filter",
|
||||
"docs_btn_reset_title": "Reset filter",
|
||||
@@ -68,7 +62,6 @@
|
||||
"docs_list_from": "From",
|
||||
"docs_list_to": "To",
|
||||
"docs_list_unknown": "Unknown",
|
||||
|
||||
"doc_section_who_when": "Who & When",
|
||||
"doc_section_description": "Description",
|
||||
"doc_section_file": "File",
|
||||
@@ -79,7 +72,6 @@
|
||||
"doc_current_file_label": "Current file:",
|
||||
"doc_new_heading": "New document",
|
||||
"doc_edit_heading": "Edit",
|
||||
|
||||
"doc_section_details": "Details",
|
||||
"doc_label_document_date": "Document date",
|
||||
"doc_label_creation_location": "Place of creation",
|
||||
@@ -92,17 +84,14 @@
|
||||
"doc_loading": "Loading document...",
|
||||
"doc_download_link": "Try direct download",
|
||||
"doc_no_scan": "No scan available",
|
||||
|
||||
"persons_heading": "Person directory",
|
||||
"persons_subtitle": "Browse the index of all recorded persons in the family archive.",
|
||||
"persons_btn_new": "New person",
|
||||
"persons_search_placeholder": "Search names...",
|
||||
"persons_empty_heading": "No persons found.",
|
||||
"persons_empty_text": "Try a different search term.",
|
||||
|
||||
"persons_new_heading": "New person",
|
||||
"persons_section_details": "Person details",
|
||||
|
||||
"person_edit_heading": "Edit person",
|
||||
"person_label_full_name": "Full name",
|
||||
"person_merge_heading": "Merge person",
|
||||
@@ -111,9 +100,21 @@
|
||||
"person_btn_merge": "Merge",
|
||||
"person_btn_merge_confirm": "Yes, merge",
|
||||
"person_merge_warning": "Warning: This action cannot be undone.",
|
||||
"person_label_notes": "Notes",
|
||||
"person_placeholder_notes": "Biographical notes, remarks…",
|
||||
"person_label_birth_year": "Birth year",
|
||||
"person_label_death_year": "Death year",
|
||||
"person_placeholder_year": "e.g. 1923",
|
||||
"person_year_error": "Please enter a four-digit year",
|
||||
"person_years_error_order": "Birth year must be before death year",
|
||||
"person_docs_heading": "Sent documents",
|
||||
"person_no_docs": "This person has not yet been linked as a sender.",
|
||||
|
||||
"person_received_docs_heading": "Received documents",
|
||||
"person_no_received_docs": "This person has not yet been linked as a receiver.",
|
||||
"person_role_sender": "Sent",
|
||||
"person_role_receiver": "Received",
|
||||
"person_co_correspondents_heading": "Frequent correspondents",
|
||||
"person_show_more": "+ {count} more",
|
||||
"conv_heading": "Conversations",
|
||||
"conv_subtitle": "Follow the correspondence between two persons chronologically.",
|
||||
"conv_label_person_a": "Person A (Sender)",
|
||||
@@ -127,7 +128,9 @@
|
||||
"conv_empty_text": "The correspondence will be shown here.",
|
||||
"conv_no_results_heading": "No documents found.",
|
||||
"conv_no_results_text": "Try adjusting the time period.",
|
||||
|
||||
"conv_swap_btn": "Swap persons",
|
||||
"conv_summary": "{count} documents · {yearFrom}–{yearTo}",
|
||||
"conv_new_doc_link": "New document in this correspondence",
|
||||
"admin_heading": "Admin Dashboard",
|
||||
"admin_tab_users": "Users",
|
||||
"admin_tab_groups": "Groups",
|
||||
@@ -156,7 +159,14 @@
|
||||
"admin_section_new_group": "Create new group",
|
||||
"admin_group_name_placeholder": "Group name (e.g. Editors)",
|
||||
"admin_user_delete_confirm": "Really delete user {username}?",
|
||||
|
||||
"admin_btn_new_user": "New User",
|
||||
"admin_user_new_heading": "Create new user",
|
||||
"admin_user_edit_heading": "Edit user: {username}",
|
||||
"admin_user_created": "User has been created.",
|
||||
"admin_user_updated": "Changes saved.",
|
||||
"admin_col_full_name": "Name",
|
||||
"admin_label_new_password_optional": "New password (optional)",
|
||||
"admin_label_initial_password": "Password",
|
||||
"doc_file_error_preview": "Could not load preview.",
|
||||
"doc_download_title": "Download",
|
||||
"doc_tag_filter_title": "Filter by {name}",
|
||||
@@ -164,9 +174,7 @@
|
||||
"doc_preview_iframe_title": "Document Preview",
|
||||
"doc_image_alt": "Original scan",
|
||||
"doc_no_date": "No date",
|
||||
|
||||
"person_merge_will_be_deleted": "will be deleted.",
|
||||
|
||||
"comp_typeahead_placeholder": "Type a name...",
|
||||
"comp_typeahead_loading": "Searching...",
|
||||
"comp_multiselect_placeholder": "Type a name...",
|
||||
@@ -176,5 +184,61 @@
|
||||
"comp_taginput_placeholder_filter": "Filter by tags...",
|
||||
"comp_taginput_remove": "Remove tag",
|
||||
"comp_taginput_create_hint": "Press Enter to create tag.",
|
||||
"person_btn_conversations": "View conversations"
|
||||
"error_email_already_in_use": "This email address is already used by another account.",
|
||||
"error_wrong_current_password": "The current password is incorrect.",
|
||||
"nav_profile": "Profile",
|
||||
"profile_heading": "My Profile",
|
||||
"profile_section_personal": "Personal Information",
|
||||
"profile_label_first_name": "First name",
|
||||
"profile_label_last_name": "Last name",
|
||||
"profile_label_birth_date": "Date of birth",
|
||||
"profile_label_email": "Email address",
|
||||
"profile_label_contact": "Contact details",
|
||||
"profile_contact_placeholder": "Phone, address or other notes...",
|
||||
"profile_section_password": "Change password",
|
||||
"profile_label_current_password": "Current password",
|
||||
"profile_label_new_password": "New password",
|
||||
"profile_label_new_password_confirm": "New password (repeat)",
|
||||
"profile_password_mismatch": "The new passwords do not match.",
|
||||
"profile_saved": "Saved.",
|
||||
"profile_password_changed": "Password changed successfully.",
|
||||
"user_profile_heading": "Profile of",
|
||||
"error_invalid_reset_token": "The link is invalid or has expired.",
|
||||
"forgot_password_heading": "Forgot password",
|
||||
"forgot_password_email_label": "Email address",
|
||||
"forgot_password_submit": "Request link",
|
||||
"forgot_password_success": "If an account with this email address exists, you will shortly receive an email with a link to reset your password.",
|
||||
"forgot_password_back_to_login": "Back to login",
|
||||
"reset_password_heading": "Set new password",
|
||||
"reset_password_label": "New password",
|
||||
"reset_password_confirm_label": "Confirm password",
|
||||
"reset_password_submit": "Save password",
|
||||
"reset_password_mismatch": "The passwords do not match.",
|
||||
"reset_password_success": "Your password has been changed successfully. You can now log in.",
|
||||
"login_forgot_password": "Forgot password?",
|
||||
"history_section_title": "History",
|
||||
"history_loading": "Loading history…",
|
||||
"history_empty": "No versions yet.",
|
||||
"history_version_label": "Version",
|
||||
"history_compare_mode": "Compare",
|
||||
"history_compare_select_a": "Version A",
|
||||
"history_compare_select_b": "Version B",
|
||||
"history_compare_apply": "Compare",
|
||||
"history_diff_no_changes": "No changes between these versions.",
|
||||
"history_field_title": "Title",
|
||||
"history_field_document_date": "Date",
|
||||
"history_field_location": "Location",
|
||||
"history_field_document_location": "Archive location",
|
||||
"history_field_transcription": "Transcription",
|
||||
"history_field_summary": "Summary",
|
||||
"history_field_sender": "Sender",
|
||||
"history_field_receivers": "Receivers",
|
||||
"history_field_tags": "Tags",
|
||||
"admin_tab_system": "System",
|
||||
"admin_system_backfill_heading": "Backfill history data",
|
||||
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
|
||||
"admin_system_backfill_btn": "Backfill now",
|
||||
"admin_system_backfill_success": "{count} documents were backfilled.",
|
||||
"comp_expandable_show_more": "Show more",
|
||||
"comp_expandable_show_less": "Show less"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"error_document_not_found": "Documento no encontrado.",
|
||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||
@@ -11,13 +10,11 @@
|
||||
"error_forbidden": "No tiene permiso para realizar esta acción.",
|
||||
"error_validation_error": "La entrada no es válida.",
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
|
||||
"nav_documents": "Documentos",
|
||||
"nav_persons": "Personas",
|
||||
"nav_conversations": "Conversaciones",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Cerrar sesión",
|
||||
|
||||
"btn_save": "Guardar",
|
||||
"btn_cancel": "Cancelar",
|
||||
"btn_edit": "Editar",
|
||||
@@ -26,7 +23,6 @@
|
||||
"btn_back_to_overview": "Volver al resumen",
|
||||
"btn_back": "Volver",
|
||||
"btn_back_to_document": "Volver al documento",
|
||||
|
||||
"form_label_first_name": "Nombre",
|
||||
"form_label_last_name": "Apellido",
|
||||
"form_label_alias": "Apodo / Alias",
|
||||
@@ -47,12 +43,10 @@
|
||||
"form_label_archive_location": "Ubicación de almacenamiento",
|
||||
"form_placeholder_archive_location": "p.ej. Armario 3, Carpeta B",
|
||||
"form_helper_archive_location": "¿Dónde se encuentra el documento original?",
|
||||
|
||||
"login_heading": "Iniciar sesión",
|
||||
"login_label_username": "Usuario",
|
||||
"login_label_password": "Contraseña",
|
||||
"login_btn_submit": "Iniciar sesión",
|
||||
|
||||
"docs_search_placeholder": "Buscar en título, contenido, lugar...",
|
||||
"docs_btn_filter": "Filtrar",
|
||||
"docs_btn_reset_title": "Restablecer filtro",
|
||||
@@ -68,7 +62,6 @@
|
||||
"docs_list_from": "De",
|
||||
"docs_list_to": "Para",
|
||||
"docs_list_unknown": "Desconocido",
|
||||
|
||||
"doc_section_who_when": "Quién & Cuándo",
|
||||
"doc_section_description": "Descripción",
|
||||
"doc_section_file": "Archivo",
|
||||
@@ -79,7 +72,6 @@
|
||||
"doc_current_file_label": "Archivo actual:",
|
||||
"doc_new_heading": "Nuevo documento",
|
||||
"doc_edit_heading": "Editar",
|
||||
|
||||
"doc_section_details": "Detalles",
|
||||
"doc_label_document_date": "Fecha del documento",
|
||||
"doc_label_creation_location": "Lugar de creación",
|
||||
@@ -92,17 +84,14 @@
|
||||
"doc_loading": "Cargando documento...",
|
||||
"doc_download_link": "Intentar descarga directa",
|
||||
"doc_no_scan": "No hay escaneo disponible",
|
||||
|
||||
"persons_heading": "Directorio de personas",
|
||||
"persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.",
|
||||
"persons_btn_new": "Nueva persona",
|
||||
"persons_search_placeholder": "Buscar nombres...",
|
||||
"persons_empty_heading": "No se encontraron personas.",
|
||||
"persons_empty_text": "Pruebe con otro término de búsqueda.",
|
||||
|
||||
"persons_new_heading": "Nueva persona",
|
||||
"persons_section_details": "Datos de la persona",
|
||||
|
||||
"person_edit_heading": "Editar persona",
|
||||
"person_label_full_name": "Nombre completo",
|
||||
"person_merge_heading": "Fusionar persona",
|
||||
@@ -111,9 +100,21 @@
|
||||
"person_btn_merge": "Fusionar",
|
||||
"person_btn_merge_confirm": "Sí, fusionar",
|
||||
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
|
||||
"person_label_notes": "Notas",
|
||||
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
||||
"person_label_birth_year": "Año de nacimiento",
|
||||
"person_label_death_year": "Año de fallecimiento",
|
||||
"person_placeholder_year": "p.ej. 1923",
|
||||
"person_year_error": "Introduzca un año de cuatro dígitos",
|
||||
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
||||
"person_docs_heading": "Documentos enviados",
|
||||
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
||||
|
||||
"person_received_docs_heading": "Documentos recibidos",
|
||||
"person_no_received_docs": "Esta persona aún no está vinculada como receptor.",
|
||||
"person_role_sender": "Enviado",
|
||||
"person_role_receiver": "Recibido",
|
||||
"person_co_correspondents_heading": "Corresponsales frecuentes",
|
||||
"person_show_more": "+ {count} más",
|
||||
"conv_heading": "Conversaciones",
|
||||
"conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.",
|
||||
"conv_label_person_a": "Persona A (Remitente)",
|
||||
@@ -127,7 +128,9 @@
|
||||
"conv_empty_text": "La correspondencia se mostrará aquí.",
|
||||
"conv_no_results_heading": "No se encontraron documentos.",
|
||||
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
||||
|
||||
"conv_swap_btn": "Intercambiar personas",
|
||||
"conv_summary": "{count} documentos · {yearFrom}–{yearTo}",
|
||||
"conv_new_doc_link": "Nuevo documento en esta correspondencia",
|
||||
"admin_heading": "Panel de administración",
|
||||
"admin_tab_users": "Usuarios",
|
||||
"admin_tab_groups": "Grupos",
|
||||
@@ -156,7 +159,14 @@
|
||||
"admin_section_new_group": "Crear nuevo grupo",
|
||||
"admin_group_name_placeholder": "Nombre del grupo (p.ej. Editores)",
|
||||
"admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?",
|
||||
|
||||
"admin_btn_new_user": "Nuevo usuario",
|
||||
"admin_user_new_heading": "Crear nuevo usuario",
|
||||
"admin_user_edit_heading": "Editar usuario: {username}",
|
||||
"admin_user_created": "Usuario creado.",
|
||||
"admin_user_updated": "Cambios guardados.",
|
||||
"admin_col_full_name": "Nombre",
|
||||
"admin_label_new_password_optional": "Nueva contraseña (opcional)",
|
||||
"admin_label_initial_password": "Contraseña",
|
||||
"doc_file_error_preview": "No se pudo cargar la vista previa.",
|
||||
"doc_download_title": "Descargar",
|
||||
"doc_tag_filter_title": "Filtrar por {name}",
|
||||
@@ -164,9 +174,7 @@
|
||||
"doc_preview_iframe_title": "Vista previa del documento",
|
||||
"doc_image_alt": "Escaneado original",
|
||||
"doc_no_date": "Sin fecha",
|
||||
|
||||
"person_merge_will_be_deleted": "será eliminado.",
|
||||
|
||||
"comp_typeahead_placeholder": "Escriba un nombre...",
|
||||
"comp_typeahead_loading": "Buscando...",
|
||||
"comp_multiselect_placeholder": "Escriba un nombre...",
|
||||
@@ -176,5 +184,61 @@
|
||||
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
|
||||
"comp_taginput_remove": "Eliminar etiqueta",
|
||||
"comp_taginput_create_hint": "Pulse Enter para crear etiqueta.",
|
||||
"person_btn_conversations": "Ver conversaciones"
|
||||
"error_email_already_in_use": "Esta dirección de correo ya está en uso por otra cuenta.",
|
||||
"error_wrong_current_password": "La contraseña actual es incorrecta.",
|
||||
"nav_profile": "Perfil",
|
||||
"profile_heading": "Mi perfil",
|
||||
"profile_section_personal": "Información personal",
|
||||
"profile_label_first_name": "Nombre",
|
||||
"profile_label_last_name": "Apellido",
|
||||
"profile_label_birth_date": "Fecha de nacimiento",
|
||||
"profile_label_email": "Correo electrónico",
|
||||
"profile_label_contact": "Datos de contacto",
|
||||
"profile_contact_placeholder": "Teléfono, dirección u otras notas...",
|
||||
"profile_section_password": "Cambiar contraseña",
|
||||
"profile_label_current_password": "Contraseña actual",
|
||||
"profile_label_new_password": "Nueva contraseña",
|
||||
"profile_label_new_password_confirm": "Nueva contraseña (repetir)",
|
||||
"profile_password_mismatch": "Las nuevas contraseñas no coinciden.",
|
||||
"profile_saved": "Guardado.",
|
||||
"profile_password_changed": "Contraseña cambiada con éxito.",
|
||||
"user_profile_heading": "Perfil de",
|
||||
"error_invalid_reset_token": "El enlace no es válido o ha expirado.",
|
||||
"forgot_password_heading": "Contraseña olvidada",
|
||||
"forgot_password_email_label": "Correo electrónico",
|
||||
"forgot_password_submit": "Solicitar enlace",
|
||||
"forgot_password_success": "Si existe una cuenta con esta dirección de correo electrónico, recibirá en breve un correo con un enlace para restablecer su contraseña.",
|
||||
"forgot_password_back_to_login": "Volver al inicio de sesión",
|
||||
"reset_password_heading": "Establecer nueva contraseña",
|
||||
"reset_password_label": "Nueva contraseña",
|
||||
"reset_password_confirm_label": "Confirmar contraseña",
|
||||
"reset_password_submit": "Guardar contraseña",
|
||||
"reset_password_mismatch": "Las contraseñas no coinciden.",
|
||||
"reset_password_success": "Su contraseña ha sido cambiada con éxito. Ahora puede iniciar sesión.",
|
||||
"login_forgot_password": "¿Olvidó su contraseña?",
|
||||
"history_section_title": "Historial",
|
||||
"history_loading": "Cargando historial…",
|
||||
"history_empty": "Aún no hay versiones.",
|
||||
"history_version_label": "Versión",
|
||||
"history_compare_mode": "Comparar",
|
||||
"history_compare_select_a": "Versión A",
|
||||
"history_compare_select_b": "Versión B",
|
||||
"history_compare_apply": "Comparar",
|
||||
"history_diff_no_changes": "No hay cambios entre estas versiones.",
|
||||
"history_field_title": "Título",
|
||||
"history_field_document_date": "Fecha",
|
||||
"history_field_location": "Lugar",
|
||||
"history_field_document_location": "Ubicación en archivo",
|
||||
"history_field_transcription": "Transcripción",
|
||||
"history_field_summary": "Resumen",
|
||||
"history_field_sender": "Remitente",
|
||||
"history_field_receivers": "Destinatarios",
|
||||
"history_field_tags": "Etiquetas",
|
||||
"admin_tab_system": "Sistema",
|
||||
"admin_system_backfill_heading": "Completar datos de historial",
|
||||
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
|
||||
"admin_system_backfill_btn": "Completar ahora",
|
||||
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
||||
"comp_expandable_show_more": "Mostrar más",
|
||||
"comp_expandable_show_less": "Mostrar menos"
|
||||
}
|
||||
|
||||
291
frontend/package-lock.json
generated
291
frontend/package-lock.json
generated
@@ -8,7 +8,9 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"openapi-fetch": "^0.13.5"
|
||||
"diff": "^8.0.3",
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
@@ -21,6 +23,7 @@
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/node": "^24",
|
||||
"@vitest/browser-playwright": "^4.0.10",
|
||||
"eslint": "^9.39.1",
|
||||
@@ -883,6 +886,256 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
|
||||
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.97",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.97",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
|
||||
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
|
||||
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
|
||||
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
|
||||
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
|
||||
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
|
||||
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
|
||||
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
|
||||
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.97",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
|
||||
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
@@ -1895,6 +2148,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/diff": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz",
|
||||
"integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2780,6 +3040,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
|
||||
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||
@@ -3936,6 +4205,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-readable-to-web-readable-stream": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -4111,6 +4387,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.5.207",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
|
||||
"integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.95",
|
||||
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
@@ -20,7 +20,9 @@
|
||||
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"openapi-fetch": "^0.13.5"
|
||||
"diff": "^8.0.3",
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
@@ -33,6 +35,7 @@
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/node": "^24",
|
||||
"@vitest/browser-playwright": "^4.0.10",
|
||||
"eslint": "^9.39.1",
|
||||
|
||||
@@ -12,7 +12,10 @@ export default defineConfig({
|
||||
// The backend + DB + MinIO must be started separately (see README or CI workflow).
|
||||
webServer: {
|
||||
command: 'npm run dev -- --port 3000',
|
||||
url: 'http://localhost:3000',
|
||||
// Use the E2E_BASE_URL so that a pre-running server (e.g. the docker dev server
|
||||
// on port 5173 during local development) is detected and reused without starting
|
||||
// a new one. In CI the default is localhost:3000 where a fresh server is started.
|
||||
url: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000
|
||||
},
|
||||
|
||||
@@ -8,9 +8,5 @@
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
},
|
||||
"baseLocale": "de",
|
||||
"locales": [
|
||||
"de",
|
||||
"en",
|
||||
"es"
|
||||
]
|
||||
"locales": ["de", "en", "es"]
|
||||
}
|
||||
|
||||
7
frontend/src/app.d.ts
vendored
7
frontend/src/app.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -5,13 +5,18 @@ import { env } from 'process';
|
||||
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
|
||||
import { detectLocale } from '$lib/server/locale';
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/logout'];
|
||||
const PUBLIC_PATHS = ['/login', '/logout', '/forgot-password', '/reset-password'];
|
||||
|
||||
const handleLocaleDetection: Handle = ({ event, resolve }) => {
|
||||
if (!event.cookies.get(cookieName)) {
|
||||
const locale = detectLocale(event.request.headers.get('accept-language') ?? '');
|
||||
if (locale) {
|
||||
event.cookies.set(cookieName, locale, { path: '/', sameSite: 'lax', maxAge: cookieMaxAge, httpOnly: false });
|
||||
event.cookies.set(cookieName, locale, {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
maxAge: cookieMaxAge,
|
||||
httpOnly: false
|
||||
});
|
||||
}
|
||||
}
|
||||
return resolve(event);
|
||||
@@ -25,65 +30,74 @@ const handleAuth: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||
event.request = request;
|
||||
const handleParaglide: Handle = ({ event, resolve }) =>
|
||||
paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||
event.request = request;
|
||||
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const userGroup: Handle = async ({ event, resolve }) => {
|
||||
const auth = event.cookies.get('auth_token');
|
||||
const auth = event.cookies.get('auth_token');
|
||||
|
||||
if (auth) {
|
||||
try {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${apiUrl}/api/users/me`, {
|
||||
headers: { Authorization: auth }
|
||||
if (auth) {
|
||||
try {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${apiUrl}/api/users/me`, {
|
||||
headers: { Authorization: auth }
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
event.locals.user = user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user in hook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
event.locals.user = user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user in hook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
const isNotLoginTest = !request.url.includes('/api/users/me');
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
|
||||
if (isApi && isNotLoginTest) {
|
||||
const token = event.cookies.get('auth_token');
|
||||
if (isApi) {
|
||||
// If the request already carries an explicit Authorization header (e.g. the
|
||||
// login action sends Basic auth), pass it through unchanged.
|
||||
if (request.headers.has('Authorization')) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
// Password reset endpoints are public — no auth header needed.
|
||||
const PUBLIC_API_PATHS = ['/api/auth/forgot-password', '/api/auth/reset-password'];
|
||||
if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// Clone the request first to preserve the body
|
||||
const clonedRequest = request.clone();
|
||||
const token = event.cookies.get('auth_token');
|
||||
|
||||
// Create new request with Authorization header and preserved body
|
||||
const modifiedRequest = new Request(clonedRequest, {
|
||||
headers: {
|
||||
...Object.fromEntries(clonedRequest.headers),
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
if (!token) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
return fetch(modifiedRequest);
|
||||
}
|
||||
// Clone the request first to preserve the body
|
||||
const clonedRequest = request.clone();
|
||||
|
||||
return fetch(request);
|
||||
// Create new request with Authorization header and preserved body
|
||||
const modifiedRequest = new Request(clonedRequest, {
|
||||
headers: {
|
||||
...Object.fromEntries(clonedRequest.headers),
|
||||
Authorization: token
|
||||
}
|
||||
});
|
||||
|
||||
return fetch(modifiedRequest);
|
||||
}
|
||||
|
||||
return fetch(request);
|
||||
};
|
||||
|
||||
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
33
frontend/src/lib/components/ExpandableText.svelte
Normal file
33
frontend/src/lib/components/ExpandableText.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { text, maxLines = 10 }: { text: string; maxLines?: number } = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let el = $state<HTMLElement | undefined>(undefined);
|
||||
let isClamped = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (el && !expanded) {
|
||||
isClamped = el.scrollHeight > el.clientHeight;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div
|
||||
bind:this={el}
|
||||
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
|
||||
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
{#if isClamped || expanded}
|
||||
<button
|
||||
onclick={() => (expanded = !expanded)}
|
||||
class="mt-2 font-sans text-xs text-gray-400 transition hover:text-brand-navy"
|
||||
>
|
||||
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
305
frontend/src/lib/components/PdfViewer.svelte
Normal file
305
frontend/src/lib/components/PdfViewer.svelte
Normal file
@@ -0,0 +1,305 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
|
||||
let { url }: { url: string } = $props();
|
||||
|
||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||
let currentPage = $state(1);
|
||||
let totalPages = $state(0);
|
||||
let scale = $state(1.5);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Canvas and text layer container refs — bound via bind:this, not reactive state
|
||||
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
||||
let textLayerEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
|
||||
let renderTask: RenderTask | null = null;
|
||||
let textLayerInstance: { cancel: () => void } | null = null;
|
||||
|
||||
// Holds the dynamically-loaded pdfjs module (browser-only)
|
||||
// Not $state — we use pdfjsReady as the reactive trigger instead
|
||||
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||
let pdfjsReady = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||
const [lib, { default: workerUrl }] = await Promise.all([
|
||||
import('pdfjs-dist'),
|
||||
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
|
||||
]);
|
||||
lib.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
pdfjsLib = lib;
|
||||
pdfjsReady = true;
|
||||
});
|
||||
|
||||
async function loadDocument(src: string) {
|
||||
if (!pdfjsLib) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
pdfDoc = null;
|
||||
currentPage = 1;
|
||||
totalPages = 0;
|
||||
|
||||
try {
|
||||
const loadingTask = pdfjsLib.getDocument(src);
|
||||
const doc = await loadingTask.promise;
|
||||
pdfDoc = doc;
|
||||
totalPages = doc.numPages;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load PDF';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage(doc: PDFDocumentProxy, pageNum: number) {
|
||||
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
|
||||
|
||||
// Cancel any in-flight render
|
||||
if (renderTask) {
|
||||
renderTask.cancel();
|
||||
renderTask = null;
|
||||
}
|
||||
if (textLayerInstance) {
|
||||
textLayerInstance.cancel();
|
||||
textLayerInstance = null;
|
||||
}
|
||||
|
||||
let page: PDFPageProxy;
|
||||
try {
|
||||
page = await doc.getPage(pageNum);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const viewport = page.getViewport({ scale: scale * dpr });
|
||||
|
||||
const canvas = canvasEl;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.style.width = `${viewport.width / dpr}px`;
|
||||
canvas.style.height = `${viewport.height / dpr}px`;
|
||||
|
||||
const task = page.render({ canvas, canvasContext: ctx, viewport });
|
||||
renderTask = task;
|
||||
|
||||
try {
|
||||
await task.promise;
|
||||
} catch (e: unknown) {
|
||||
if (
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'name' in e &&
|
||||
(e as { name: string }).name === 'RenderingCancelledException'
|
||||
)
|
||||
return;
|
||||
return;
|
||||
}
|
||||
renderTask = null;
|
||||
|
||||
// Text layer
|
||||
const textDiv = textLayerEl;
|
||||
textDiv.innerHTML = '';
|
||||
textDiv.style.width = `${viewport.width / dpr}px`;
|
||||
textDiv.style.height = `${viewport.height / dpr}px`;
|
||||
|
||||
const tl = new pdfjsLib.TextLayer({
|
||||
textContentSource: page.streamTextContent(),
|
||||
container: textDiv,
|
||||
viewport
|
||||
});
|
||||
textLayerInstance = tl;
|
||||
try {
|
||||
await tl.render();
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
|
||||
for (const n of neighbors) {
|
||||
try {
|
||||
await doc.getPage(n);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (pdfjsReady && url) {
|
||||
loadDocument(url);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Read scale synchronously so Svelte tracks it as a dependency.
|
||||
// Without this, zoom changes don't re-trigger the effect because
|
||||
// scale is only read inside the async renderPage call.
|
||||
if (pdfDoc && currentPage && scale > 0) {
|
||||
renderPage(pdfDoc, currentPage).then(() => {
|
||||
if (pdfDoc) prerender(pdfDoc, currentPage);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) currentPage -= 1;
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale += 0.25;
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (scale > 0.5) scale -= 0.25;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !url}
|
||||
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400">
|
||||
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
|
||||
>
|
||||
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-sans text-xs text-brand-mint underline hover:opacity-80"
|
||||
>
|
||||
Direkt öffnen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
||||
<!-- Controls -->
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
||||
>
|
||||
<!-- Page navigation -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={prevPage}
|
||||
disabled={currentPage <= 1}
|
||||
aria-label="Zurück"
|
||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if totalPages > 0}
|
||||
<span class="font-sans text-xs text-gray-300 tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={nextPage}
|
||||
disabled={!pdfDoc || currentPage >= totalPages}
|
||||
aria-label="Weiter"
|
||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Zoom controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={zoomOut}
|
||||
aria-label="Verkleinern"
|
||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" /><path
|
||||
stroke-linecap="round"
|
||||
d="M21 21l-4.35-4.35M8 11h6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={zoomIn}
|
||||
aria-label="Vergrößern"
|
||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" /><path
|
||||
stroke-linecap="round"
|
||||
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF canvas area -->
|
||||
<div class="relative flex-1 overflow-auto">
|
||||
{#if loading}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-full items-start justify-center p-4">
|
||||
<div
|
||||
class="pdf-page relative shadow-xl"
|
||||
data-page-number={currentPage}
|
||||
style="position: relative"
|
||||
>
|
||||
<canvas bind:this={canvasEl}></canvas>
|
||||
<div
|
||||
bind:this={textLayerEl}
|
||||
class="textLayer"
|
||||
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
53
frontend/src/lib/components/PdfViewer.svelte.spec.ts
Normal file
53
frontend/src/lib/components/PdfViewer.svelte.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need
|
||||
// a real browser PDF engine. The interesting behaviour under test here is the
|
||||
// component's own UI logic (controls, page counter), not pdfjs internals.
|
||||
vi.mock('pdfjs-dist', () => {
|
||||
function TextLayerMock() {}
|
||||
TextLayerMock.prototype.render = () => Promise.resolve();
|
||||
TextLayerMock.prototype.cancel = () => {};
|
||||
|
||||
return {
|
||||
GlobalWorkerOptions: { workerSrc: '' },
|
||||
getDocument: vi.fn().mockReturnValue({
|
||||
promise: Promise.resolve({
|
||||
numPages: 2,
|
||||
getPage: vi.fn().mockResolvedValue({
|
||||
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
|
||||
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
|
||||
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
|
||||
})
|
||||
})
|
||||
}),
|
||||
TextLayer: TextLayerMock
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
|
||||
|
||||
import PdfViewer from './PdfViewer.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('PdfViewer', () => {
|
||||
it('shows previous and next page navigation buttons', async () => {
|
||||
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zoom controls', async () => {
|
||||
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the page counter once the PDF has loaded', async () => {
|
||||
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||
// Mock resolves synchronously, so "1 / 2" should appear quickly
|
||||
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,123 +1,143 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
selectedPersons?: Person[];
|
||||
}
|
||||
interface Props {
|
||||
selectedPersons?: Person[];
|
||||
}
|
||||
|
||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
let searchTerm = $state('');
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||
}
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (searchTerm.length < 1) { results = []; return; }
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||
if (res.ok) {
|
||||
const all: Person[] = await res.json();
|
||||
results = all.filter(p => !selectedPersons.some(s => s.id === p.id));
|
||||
}
|
||||
} catch { results = []; }
|
||||
finally { loading = false; }
|
||||
}, 300);
|
||||
}
|
||||
function handleInput() {
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (searchTerm.length < 1) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||
if (res.ok) {
|
||||
const all: Person[] = await res.json();
|
||||
results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id));
|
||||
}
|
||||
} catch {
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function selectPerson(person: Person) {
|
||||
selectedPersons = [...selectedPersons, person];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
}
|
||||
function selectPerson(person: Person) {
|
||||
selectedPersons = [...selectedPersons, person];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
}
|
||||
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter(p => p.id !== id);
|
||||
}
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return { destroy() { document.removeEventListener('click', handleClick, true); } };
|
||||
}
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
{#each selectedPersons as person}
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<div class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded bg-white min-h-[42px] focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy">
|
||||
{#each selectedPersons as person}
|
||||
<span class="inline-flex items-center gap-1 bg-brand-sand/40 text-brand-navy text-sm font-medium px-2 py-1 rounded">
|
||||
{person.firstName} {person.lastName}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
||||
>
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-brand-sand/40 px-2 py-1 text-sm font-medium text-brand-navy"
|
||||
>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="ml-0.5 text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
|
||||
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
|
||||
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
class="z-50 bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-gray-500 text-sm">{m.comp_multiselect_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person}
|
||||
<div
|
||||
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{person.lastName}, {person.firstName}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-sm text-gray-500">{m.comp_multiselect_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person (person.id)}
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-brand-sand/30"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{person.lastName}, {person.firstName}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonMultiSelect from './PersonMultiSelect.svelte';
|
||||
|
||||
@@ -29,6 +29,7 @@ function receiverInputs() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,129 +1,154 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
import { untrack } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
value = $bindable(''),
|
||||
initialName = '',
|
||||
restrictToCorrespondentsOf,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let searchTerm = $state(initialName);
|
||||
|
||||
// Sync with external changes (e.g. reset button) — also sets the initial value
|
||||
$effect(() => {
|
||||
searchTerm = initialName;
|
||||
});
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
function handleInput() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
onchange?.('');
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
onchange?.('');
|
||||
}
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const term = untrack(() => searchTerm);
|
||||
const correspondentsOf = untrack(() => restrictToCorrespondentsOf);
|
||||
loading = true;
|
||||
try {
|
||||
let url: string;
|
||||
if (correspondentsOf) {
|
||||
if (term.length >= 1) {
|
||||
url = `/api/persons/${correspondentsOf}/correspondents?q=${encodeURIComponent(term)}`;
|
||||
} else {
|
||||
url = `/api/persons/${correspondentsOf}/correspondents`;
|
||||
}
|
||||
} else {
|
||||
if (term.length < 1) {
|
||||
results = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
url = `/api/persons?q=${encodeURIComponent(term)}`;
|
||||
}
|
||||
const res = await fetch(url);
|
||||
results = res.ok ? await res.json() : [];
|
||||
} catch (e) {
|
||||
console.error('Suche fehlgeschlagen', e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (searchTerm.length < 1) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||
results = res.ok ? await res.json() : [];
|
||||
} catch (e) {
|
||||
console.error('Suche fehlgeschlagen', e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
function handleFocus() {
|
||||
showDropdown = true;
|
||||
if (restrictToCorrespondentsOf) {
|
||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||
loading = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/persons/${personId}/correspondents`);
|
||||
results = res.ok ? await res.json() : [];
|
||||
} catch (e) {
|
||||
console.error('Suche fehlgeschlagen', e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() { document.removeEventListener('click', handleClick, true); }
|
||||
};
|
||||
}
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
|
||||
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
|
||||
|
||||
<input type="hidden" {name} bind:value={value} />
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={m.comp_typeahead_placeholder()}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
placeholder={m.comp_typeahead_placeholder()}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
class="z-50 bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-gray-500 text-sm">{m.comp_typeahead_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person}
|
||||
<div
|
||||
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="font-medium block truncate">
|
||||
{person.lastName}, {person.firstName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person (person.id)}
|
||||
<div
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-blue-100"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">
|
||||
{person.lastName}, {person.firstName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
@@ -30,6 +30,7 @@ function hiddenInput(name: string) {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -117,9 +118,12 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
|
||||
.not.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||
});
|
||||
|
||||
@@ -129,7 +133,8 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||
});
|
||||
@@ -141,7 +146,8 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
@@ -151,7 +157,8 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
});
|
||||
});
|
||||
@@ -167,7 +174,8 @@ describe('PersonTypeahead – clearing a selection', () => {
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
onchange.mockClear();
|
||||
|
||||
@@ -179,6 +187,69 @@ describe('PersonTypeahead – clearing a selection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Correspondent mode ───────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – correspondent mode', () => {
|
||||
it('fetches correspondents immediately on focus when restrictToCorrespondentsOf is set', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, {
|
||||
name: 'receiverId',
|
||||
label: 'Empfänger',
|
||||
restrictToCorrespondentsOf: 'person-a-id'
|
||||
});
|
||||
|
||||
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/persons/person-a-id/correspondents')
|
||||
);
|
||||
});
|
||||
|
||||
it('shows correspondent results immediately on focus without typing', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, {
|
||||
name: 'receiverId',
|
||||
label: 'Empfänger',
|
||||
restrictToCorrespondentsOf: 'person-a-id'
|
||||
});
|
||||
|
||||
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
|
||||
await waitForDebounce();
|
||||
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses correspondents endpoint with q param when typing', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, {
|
||||
name: 'receiverId',
|
||||
label: 'Empfänger',
|
||||
restrictToCorrespondentsOf: 'person-a-id'
|
||||
});
|
||||
|
||||
await page.getByPlaceholder('Namen tippen...').fill('Anna');
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/persons/person-a-id/correspondents?q=Anna')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses normal persons endpoint when restrictToCorrespondentsOf is not set', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
|
||||
await page.getByPlaceholder('Namen tippen...').fill('Anna');
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Anna'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Click outside ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – click outside', () => {
|
||||
|
||||
@@ -1,150 +1,156 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
allowCreation?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
allowCreation?: boolean;
|
||||
}
|
||||
|
||||
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
||||
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
||||
|
||||
let inputVal = $state('');
|
||||
let suggestions: string[] = $state([]);
|
||||
let activeIndex = $state(-1);
|
||||
let showSuggestions = $state(false);
|
||||
let inputVal = $state('');
|
||||
let suggestions: string[] = $state([]);
|
||||
let activeIndex = $state(-1);
|
||||
let showSuggestions = $state(false);
|
||||
|
||||
async function fetchSuggestions(query: string) {
|
||||
if (query.length < 2) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const names: string[] = data.map((t: { name: string }) => t.name);
|
||||
suggestions = names.filter((t) => !tags.includes(t));
|
||||
showSuggestions = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Tag fetch error', e);
|
||||
}
|
||||
}
|
||||
async function fetchSuggestions(query: string) {
|
||||
if (query.length < 2) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const names: string[] = data.map((t: { name: string }) => t.name);
|
||||
const currentTags = untrack(() => tags);
|
||||
suggestions = names.filter((t) => !currentTags.includes(t));
|
||||
showSuggestions = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Tag fetch error', e);
|
||||
}
|
||||
}
|
||||
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags = [...tags, trimmed];
|
||||
}
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags = [...tags, trimmed];
|
||||
}
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function removeTag(index: number) {
|
||||
tags = tags.filter((_, i) => i !== index);
|
||||
}
|
||||
function removeTag(index: number) {
|
||||
tags = tags.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
||||
addTag(suggestions[activeIndex]);
|
||||
} else if (allowCreation) {
|
||||
addTag(inputVal);
|
||||
}
|
||||
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
||||
removeTag(tags.length - 1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
}
|
||||
}
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
||||
addTag(suggestions[activeIndex]);
|
||||
} else if (allowCreation) {
|
||||
addTag(inputVal);
|
||||
}
|
||||
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
||||
removeTag(tags.length - 1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showSuggestions = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return { destroy() { document.removeEventListener('click', handleClick, true); } };
|
||||
}
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showSuggestions = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full" use:clickOutside>
|
||||
<!-- Tag Container -->
|
||||
<div
|
||||
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy bg-white min-h-[42px]"
|
||||
>
|
||||
<!-- Render Selected Tags -->
|
||||
{#each tags as tag, i}
|
||||
<span
|
||||
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(i)}
|
||||
aria-label={m.comp_taginput_remove()}
|
||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<!-- Tag Container -->
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
||||
>
|
||||
<!-- Render Selected Tags -->
|
||||
{#each tags as tag, i (i)}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(i)}
|
||||
aria-label={m.comp_taginput_remove()}
|
||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<!-- Input Field -->
|
||||
<div class="relative flex-1 min-w-[120px]">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inputVal}
|
||||
oninput={() => fetchSuggestions(inputVal)}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={() => fetchSuggestions(inputVal)}
|
||||
placeholder={tags.length === 0
|
||||
<!-- Input Field -->
|
||||
<div class="relative min-w-[120px] flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inputVal}
|
||||
oninput={() => fetchSuggestions(inputVal)}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={() => fetchSuggestions(inputVal)}
|
||||
placeholder={tags.length === 0
|
||||
? allowCreation
|
||||
? m.comp_taginput_placeholder_create()
|
||||
: m.comp_taginput_placeholder_filter()
|
||||
: ''}
|
||||
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||
/>
|
||||
class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
|
||||
<!-- Typeahead Dropdown -->
|
||||
{#if showSuggestions && suggestions.length > 0}
|
||||
<ul
|
||||
class="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded shadow-lg z-50 max-h-48 overflow-y-auto"
|
||||
>
|
||||
{#each suggestions as suggestion, i}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
tabindex="0"
|
||||
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex
|
||||
? 'bg-brand-sand/20 text-brand-navy font-bold'
|
||||
<!-- Typeahead Dropdown -->
|
||||
{#if showSuggestions && suggestions.length > 0}
|
||||
<ul
|
||||
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each suggestions as suggestion, i (i)}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
tabindex="0"
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-brand-sand/20 {i === activeIndex
|
||||
? 'bg-brand-sand/20 font-bold text-brand-navy'
|
||||
: 'text-gray-700'}"
|
||||
onclick={() => addTag(suggestion)}
|
||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if allowCreation}
|
||||
<p class="text-xs text-gray-400 mt-1">{m.comp_taginput_create_hint()}</p>
|
||||
{/if}
|
||||
onclick={() => addTag(suggestion)}
|
||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if allowCreation}
|
||||
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import TagInput from './TagInput.svelte';
|
||||
|
||||
@@ -24,6 +24,7 @@ function mockFetchEmpty() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -32,17 +33,13 @@ afterEach(() => {
|
||||
describe('TagInput – rendering', () => {
|
||||
it('shows creation placeholder when allowCreation=true and no tags', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Schlagworte hinzufügen...'))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder('Schlagworte hinzufügen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
|
||||
});
|
||||
|
||||
it('shows filter placeholder when allowCreation=false', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Nach Schlagworten filtern...'))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder('Nach Schlagworten filtern...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing tags as chips', async () => {
|
||||
@@ -117,8 +114,8 @@ describe('TagInput – removing tags', () => {
|
||||
it('removes a chip when its × button is clicked', async () => {
|
||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
||||
// The × buttons have aria-label="Schlagwort entfernen"
|
||||
const removeButtons = page.getByRole('button', { name: 'Schlagwort entfernen' });
|
||||
await removeButtons.first().click();
|
||||
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Familie')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-after-remove.png' });
|
||||
@@ -126,8 +123,7 @@ describe('TagInput – removing tags', () => {
|
||||
|
||||
it('removes the last tag on Backspace when the input is empty', async () => {
|
||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.click();
|
||||
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
@@ -181,7 +177,8 @@ describe('TagInput – autocomplete', () => {
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForDebounce();
|
||||
await page.getByRole('option', { name: 'Familie' }).click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestion-selected.png' });
|
||||
|
||||
@@ -5,20 +5,23 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
* Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
|
||||
*/
|
||||
export type ErrorCode =
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'FILE_UPLOAD_FAILED'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'IMPORT_ALREADY_RUNNING'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'INTERNAL_ERROR';
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'FILE_UPLOAD_FAILED'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'EMAIL_ALREADY_IN_USE'
|
||||
| 'WRONG_CURRENT_PASSWORD'
|
||||
| 'IMPORT_ALREADY_RUNNING'
|
||||
| 'INVALID_RESET_TOKEN'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
export interface BackendError {
|
||||
code: ErrorCode;
|
||||
message: string; // English developer message — not shown to users
|
||||
code: ErrorCode;
|
||||
message: string; // English developer message — not shown to users
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,29 +29,45 @@ export interface BackendError {
|
||||
* Returns null if the body is not valid JSON or does not contain a code field.
|
||||
*/
|
||||
export async function parseBackendError(res: Response): Promise<BackendError | null> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body.code === 'string') {
|
||||
return body as BackendError;
|
||||
}
|
||||
} catch {
|
||||
// Body was not JSON
|
||||
}
|
||||
return null;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body.code === 'string') {
|
||||
return body as BackendError;
|
||||
}
|
||||
} catch {
|
||||
// Body was not JSON
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */
|
||||
export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
switch (code) {
|
||||
case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found();
|
||||
case 'DOCUMENT_NO_FILE': return m.error_document_no_file();
|
||||
case 'FILE_NOT_FOUND': return m.error_file_not_found();
|
||||
case 'FILE_UPLOAD_FAILED': return m.error_file_upload_failed();
|
||||
case 'USER_NOT_FOUND': return m.error_user_not_found();
|
||||
case 'IMPORT_ALREADY_RUNNING':return m.error_import_already_running();
|
||||
case 'UNAUTHORIZED': return m.error_unauthorized();
|
||||
case 'FORBIDDEN': return m.error_forbidden();
|
||||
case 'VALIDATION_ERROR': return m.error_validation_error();
|
||||
default: return m.error_internal_error();
|
||||
}
|
||||
switch (code) {
|
||||
case 'DOCUMENT_NOT_FOUND':
|
||||
return m.error_document_not_found();
|
||||
case 'DOCUMENT_NO_FILE':
|
||||
return m.error_document_no_file();
|
||||
case 'FILE_NOT_FOUND':
|
||||
return m.error_file_not_found();
|
||||
case 'FILE_UPLOAD_FAILED':
|
||||
return m.error_file_upload_failed();
|
||||
case 'USER_NOT_FOUND':
|
||||
return m.error_user_not_found();
|
||||
case 'EMAIL_ALREADY_IN_USE':
|
||||
return m.error_email_already_in_use();
|
||||
case 'WRONG_CURRENT_PASSWORD':
|
||||
return m.error_wrong_current_password();
|
||||
case 'IMPORT_ALREADY_RUNNING':
|
||||
return m.error_import_already_running();
|
||||
case 'INVALID_RESET_TOKEN':
|
||||
return m.error_invalid_reset_token();
|
||||
case 'UNAUTHORIZED':
|
||||
return m.error_unauthorized();
|
||||
case 'FORBIDDEN':
|
||||
return m.error_forbidden();
|
||||
case 'VALIDATION_ERROR':
|
||||
return m.error_validation_error();
|
||||
default:
|
||||
return m.error_internal_error();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,38 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/users/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getUser"];
|
||||
put: operations["adminUpdateUser"];
|
||||
post?: never;
|
||||
delete: operations["deleteUser"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getCurrentUser"];
|
||||
put: operations["updateProfile"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tags/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -68,6 +100,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me/password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["changePassword"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -132,6 +180,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/reset-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["resetPassword"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/forgot-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["forgotPassword"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/trigger-import": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -148,6 +228,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["backfillVersions"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/groups/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -164,22 +260,6 @@ export interface paths {
|
||||
patch: operations["updateGroup"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getCurrentUser"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tags": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -196,6 +276,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/received-documents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getPersonReceivedDocuments"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/documents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -212,6 +308,54 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/correspondents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getCorrespondents"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getVersions"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/versions/{versionId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getVersion"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/file": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -276,37 +420,76 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteUser"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
AdminUpdateUserRequest: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: date */
|
||||
birthDate?: string;
|
||||
email?: string;
|
||||
contact?: string;
|
||||
newPassword?: string;
|
||||
groupIds?: string[];
|
||||
};
|
||||
AppUser: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: date */
|
||||
birthDate?: string;
|
||||
email?: string;
|
||||
contact?: string;
|
||||
enabled: boolean;
|
||||
groups: components["schemas"]["UserGroup"][];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
UserGroup: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
};
|
||||
UpdateProfileDTO: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: date */
|
||||
birthDate?: string;
|
||||
email?: string;
|
||||
contact?: string;
|
||||
};
|
||||
Tag: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
PersonUpdateDTO: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
};
|
||||
Person: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
};
|
||||
DocumentUpdateDTO: {
|
||||
title?: string;
|
||||
@@ -351,28 +534,27 @@ export interface components {
|
||||
email?: string;
|
||||
initialPassword?: string;
|
||||
groupIds?: string[];
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: date */
|
||||
birthDate?: string;
|
||||
contact?: string;
|
||||
};
|
||||
AppUser: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
email?: string;
|
||||
enabled: boolean;
|
||||
groups: components["schemas"]["UserGroup"][];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
UserGroup: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
ChangePasswordDTO: {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
GroupDTO: {
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
};
|
||||
ResetPasswordRequest: {
|
||||
token?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
ForgotPasswordRequest: {
|
||||
email?: string;
|
||||
};
|
||||
ImportStatus: {
|
||||
/** @enum {string} */
|
||||
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
||||
@@ -382,6 +564,31 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
startedAt?: string;
|
||||
};
|
||||
BackfillResult: {
|
||||
/** Format: int32 */
|
||||
count: number;
|
||||
};
|
||||
DocumentVersionSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
savedAt: string;
|
||||
editorName: string;
|
||||
changedFields: string[];
|
||||
};
|
||||
DocumentVersion: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
/** Format: date-time */
|
||||
savedAt: string;
|
||||
/** Format: uuid */
|
||||
editorId?: string;
|
||||
editorName: string;
|
||||
snapshot: string;
|
||||
changedFields: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@@ -391,6 +598,118 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
getUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
adminUpdateUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminUpdateUserRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getCurrentUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateProfile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateProfileDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateTag: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -472,9 +791,7 @@ export interface operations {
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
"application/json": components["schemas"]["PersonUpdateDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
@@ -581,6 +898,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
changePassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChangePasswordDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getPersons: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -723,6 +1062,50 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
resetPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ResetPasswordRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
forgotPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForgotPasswordRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
triggerMassImport: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -743,6 +1126,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillVersions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BackfillResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -789,26 +1192,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getCurrentUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
searchTags: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -831,6 +1214,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getPersonReceivedDocuments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getPersonDocuments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -853,6 +1258,75 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getCorrespondents: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Person"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getVersions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentVersionSummary"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getVersion: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
versionId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentVersion"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentFile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -948,24 +1422,4 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
11
frontend/src/lib/utils/date.ts
Normal file
11
frontend/src/lib/utils/date.ts
Normal 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'));
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { sortDocumentsByDate } from './sort';
|
||||
|
||||
const doc = (id: string, documentDate: string | null) =>
|
||||
({ id, documentDate } as { id: string; documentDate: string | null });
|
||||
({ id, documentDate }) as { id: string; documentDate: string | null };
|
||||
|
||||
describe('sortDocumentsByDate', () => {
|
||||
it('sorts DESC by default — newest first', () => {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,121 +1,196 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||
import './layout.css';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||
|
||||
let { children } = $props();
|
||||
let { children, data } = $props();
|
||||
|
||||
const locales = ['DE', 'EN', 'ES'] as const;
|
||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||
const activeLocale = $derived(getLocale().toUpperCase());
|
||||
const locales = ['DE', 'EN', 'ES'] as const;
|
||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||
const activeLocale = $derived(getLocale().toUpperCase());
|
||||
|
||||
const isAdmin = $derived(page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')));
|
||||
const isAdmin = $derived(
|
||||
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
|
||||
);
|
||||
|
||||
// Set after client-side hydration completes. Used by E2E tests to know the
|
||||
// page is interactive (event handlers registered) before they interact with it.
|
||||
let hydrated = $state(false);
|
||||
onMount(() => { hydrated = true; });
|
||||
// Set after client-side hydration completes. Used by E2E tests to know the
|
||||
// page is interactive (event handlers registered) before they interact with it.
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
|
||||
let userMenuOpen = $state(false);
|
||||
|
||||
const userInitials = $derived.by(() => {
|
||||
const first = data?.user?.firstName?.[0];
|
||||
const last = data?.user?.lastName?.[0];
|
||||
if (first && last) return (first + last).toUpperCase();
|
||||
return null;
|
||||
});
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
userMenuOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
|
||||
{#if !['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))}
|
||||
<header class="sticky top-0 z-50 border-b border-gray-100 bg-white">
|
||||
<!-- De Gruyter Brill purple accent strip -->
|
||||
<div class="h-1 bg-brand-purple"></div>
|
||||
|
||||
{#if !page.url.pathname.startsWith('/login')}
|
||||
<header class="bg-white border-b border-gray-100 sticky top-0 z-50">
|
||||
<!-- De Gruyter Brill purple accent strip -->
|
||||
<div class="h-1 bg-brand-purple"></div>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 justify-between">
|
||||
<!-- Logo & Nav -->
|
||||
<div class="flex">
|
||||
<div class="mr-10 flex flex-shrink-0 items-center">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-xl font-bold tracking-widest text-brand-navy uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
|
||||
<!-- Logo & Nav -->
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center mr-10">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans font-bold text-xl tracking-widest text-brand-navy uppercase">Familienarchiv</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="hidden sm:flex sm:space-x-1 items-center">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
||||
<nav class="hidden items-center sm:flex sm:space-x-1">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/persons')
|
||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/conversations"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
||||
<a
|
||||
href="/conversations"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/conversations')
|
||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/admin')
|
||||
? 'text-brand-navy bg-brand-purple/15 rounded'
|
||||
: 'text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Right Side -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center gap-1 border-r border-gray-200 pr-3">
|
||||
{#each locales as locale}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(localeMap[locale])}
|
||||
class="text-xs font-sans tracking-widest px-1.5 py-1 transition-colors
|
||||
<!-- Right Side -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center gap-1 border-r border-gray-200 pr-3">
|
||||
{#each locales as locale (locale)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(localeMap[locale])}
|
||||
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
||||
{activeLocale === locale
|
||||
? 'font-bold text-brand-navy'
|
||||
: 'font-normal text-gray-400 hover:text-brand-navy'}"
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<form action="/logout" method="POST" use:enhance>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-gray-400 hover:text-brand-navy font-bold uppercase font-sans tracking-widest px-3 py-2 transition-colors"
|
||||
>
|
||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg" alt="" aria-hidden="true" class="w-4 h-4 opacity-50" />
|
||||
{m.nav_logout()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
<!-- User menu -->
|
||||
<div
|
||||
class="relative"
|
||||
{@attach clickOutside}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }}
|
||||
role="none"
|
||||
>
|
||||
{#if userInitials}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.nav_profile()}
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 opacity-50"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<main class="py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
{#if userMenuOpen}
|
||||
<div
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-brand-sand bg-white shadow-md"
|
||||
>
|
||||
<a
|
||||
href="/profile"
|
||||
onclick={() => (userMenuOpen = false)}
|
||||
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
|
||||
>
|
||||
{m.nav_profile()}
|
||||
</a>
|
||||
<div class="border-t border-brand-sand">
|
||||
<form action="/logout" method="POST" use:enhance>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
|
||||
>
|
||||
{m.nav_logout()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<main class="py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -2,59 +2,60 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const tags = url.searchParams.getAll('tag');
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
const to = url.searchParams.get('to') || '';
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const tags = url.searchParams.getAll('tag');
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
try {
|
||||
const [docsResult, personsResult] = await Promise.all([
|
||||
api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
api.GET('/api/persons')
|
||||
]);
|
||||
try {
|
||||
const [docsResult, personsResult] = await Promise.all([
|
||||
api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
api.GET('/api/persons')
|
||||
]);
|
||||
|
||||
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
const documents = docsResult.data ?? [];
|
||||
const allPersons: { id: string; firstName: string; lastName: string }[] = personsResult.data ?? [];
|
||||
const documents = docsResult.data ?? [];
|
||||
const allPersons: { id: string; firstName: string; lastName: string }[] =
|
||||
personsResult.data ?? [];
|
||||
|
||||
const senderObj = allPersons.find(p => p.id === senderId);
|
||||
const receiverObj = allPersons.find(p => p.id === receiverId);
|
||||
const senderObj = allPersons.find((p) => p.id === senderId);
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
return {
|
||||
documents,
|
||||
initialValues: {
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||
},
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
if ((e as { status?: number }).status) throw e;
|
||||
console.error('Error loading data:', e);
|
||||
return {
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
return {
|
||||
documents,
|
||||
initialValues: {
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||
},
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
if ((e as { status?: number }).status) throw e;
|
||||
console.error('Error loading data:', e);
|
||||
return {
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user