Compare commits

...

10 Commits

Author SHA1 Message Date
Marcel
ed32e1728d doc: add collab rules
Some checks failed
CI / Unit & Component Tests (push) Successful in 16m0s
CI / E2E Tests (push) Failing after 1m23s
2026-03-17 16:07:50 +00:00
Marcel
273b43261f chore: update ignore file 2026-03-17 13:35:32 +00:00
Marcel
7cb20dec50 test: add e2e tests 2026-03-17 13:34:05 +00:00
Marcel
973620a097 build: add mvn properties 2026-03-17 13:33:02 +00:00
Marcel
b3de5f885d ci: add workflows 2026-03-17 13:32:12 +00:00
Marcel
4417fc9828 refactor: migrate all Svelte components from Svelte 4 to Svelte 5 runes
- Replace `export let` with `$props()` and `$bindable()` across all components
- Replace `$:` reactive statements with `$derived()` and `$effect()`
- Replace `createEventDispatcher` with callback props (e.g. `onchange`)
- Replace `on:event` directives with inline event handlers (`onclick`, `oninput`, etc.)
- Replace `<slot />` with `{@render children()}` in layout
- Use `untrack()` for SSR-safe $state initialization from reactive props
- Replace `blur` + `setTimeout` anti-pattern in TagInput with `clickOutside` action
- Fix `page` store usage in layout to use `$app/state` directly
- 0 errors, 0 warnings after svelte-check

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

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

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

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

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

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

@@ -0,0 +1,136 @@
name: CI
on:
push:
pull_request:
jobs:
# ─── Unit & Browser Component Tests ──────────────────────────────────────────
# No backend needed — Vitest runs in Node (utils) and headless Chromium (components).
unit-tests:
name: Unit & Component Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Install Playwright Chromium (used by vitest browser mode)
run: npx playwright install chromium --with-deps
working-directory: frontend
- name: Run unit and component tests
run: npm test
working-directory: frontend
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-screenshots
path: frontend/test-results/screenshots/
# ─── E2E Tests ────────────────────────────────────────────────────────────────
# Needs: PostgreSQL + MinIO (via docker-compose) + Spring Boot + SvelteKit dev server.
# Test data is seeded by DataInitializer on first startup (admin user + e2e profile data).
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
# These env vars are picked up by docker-compose (overrides .env file)
env:
POSTGRES_USER: archive_user
POSTGRES_PASSWORD: ci_db_password
POSTGRES_DB: family_archive_db
MINIO_ROOT_USER: minio_admin
MINIO_ROOT_PASSWORD: ci_minio_password
MINIO_DEFAULT_BUCKETS: archive-documents
PORT_DB: 5432
PORT_MINIO_API: 9000
PORT_MINIO_CONSOLE: 9001
PORT_BACKEND: 8080
PORT_FRONTEND: 3000
steps:
- uses: actions/checkout@v4
# ── Infrastructure ──────────────────────────────────────────────────────
- name: Start DB and MinIO
run: docker-compose up -d db minio create-buckets
- name: Wait for DB to be ready
run: |
timeout 30 bash -c \
'until docker-compose exec -T db pg_isready -U archive_user; do sleep 2; done'
# ── Backend ─────────────────────────────────────────────────────────────
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: temurin
cache: maven
- name: Build backend (skip tests — covered by separate Java test job)
run: |
chmod +x mvnw
./mvnw clean package -DskipTests
working-directory: backend
- name: Start backend
run: |
java -jar backend/target/*.jar \
--spring.profiles.active=e2e \
--SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/family_archive_db \
--SPRING_DATASOURCE_USERNAME=archive_user \
--SPRING_DATASOURCE_PASSWORD=ci_db_password \
--S3_ENDPOINT=http://localhost:9000 \
--S3_ACCESS_KEY=minio_admin \
--S3_SECRET_KEY=ci_minio_password \
--S3_BUCKET_NAME=archive-documents \
--S3_REGION=us-east-1 \
--APP_ADMIN_USERNAME=admin \
--APP_ADMIN_PASSWORD=admin123 \
&
echo "Waiting for backend..."
timeout 90 bash -c \
'until curl -sf http://localhost:8080/actuator/health | grep -q "UP"; do sleep 3; done'
echo "Backend is up."
# ── Frontend ─────────────────────────────────────────────────────────────
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Install Playwright Chromium
run: npx playwright install chromium --with-deps
working-directory: frontend
# ── Tests ────────────────────────────────────────────────────────────────
- name: Run E2E tests
run: npm run test:e2e
working-directory: frontend
env:
E2E_BASE_URL: http://localhost:3000
E2E_USERNAME: admin
E2E_PASSWORD: admin123
- name: Upload E2E results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-results
path: frontend/test-results/e2e/

5
.gitignore vendored
View File

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

321
CLAUDE.md
View File

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

80
COLLABORATING.md Normal file
View File

@@ -0,0 +1,80 @@
# Collaboration Rules
How we work together on this project.
## Honesty and Objectivity
Evaluate all suggestions on their technical merits. No sycophancy — if something doesn't make sense, doesn't align with best practices, or could be improved, say so directly and constructively. Technical accuracy and project quality take precedence over being agreeable.
## Core Workflow: Research → Plan → Implement → Validate
Every non-trivial feature or bug fix follows this sequence:
1. **Research** — Read the relevant code. Understand existing patterns before touching anything.
2. **Plan** — Write a plan to `/.agent/current-plan.md` and align with the user before writing code. Update the plan as work progresses.
3. **Implement** — Build with tests and error handling. Use pure functions where possible.
4. **Validate** — Run formatters, linters, and tests after every implementation step.
Never start writing code without having read the relevant files first.
## Issue Tracking (Gitea)
All work is tracked in **Gitea** at `http://192.168.178.71:3005` (repo `marcel/familienarchiv`). Never use todo files or CLAUDE.md notes as a substitute.
Create an issue whenever work is identified that isn't being done in the current session.
### Issue title formats
**`feature` label** — user story format:
```
As a [role] I want [capability] so/because [reason]
```
Examples:
- "As a user I want to search documents so I can find a specific document faster"
- "As an admin I want to add a new user so I don't have to restart the server"
**`bug` label** — user-facing impact, not the technical cause:
```
[What breaks] when [trigger]
```
Examples:
- "Document list shows blank page when no results found"
- "Upload fails silently when file exceeds 50MB"
**`devops` label** — infrastructure, CI/CD, deployment, tooling:
- "Fix CI checkout failing due to unresolvable hostname"
- "Add E2E test seed data for runner"
### Priority labels
- `priority: high` — blocking or urgent
- `priority: medium` — normal
- `priority: low` — nice to have
### Other labels
- `needs-discussion` — decision needed before work starts
- `wontfix` — acknowledged, not addressing
## Commit Messages
Every commit must reference the relevant Gitea issue.
- `Closes #12` — commit fully resolves the issue (Gitea auto-closes it)
- `Refs #12` — commit is related but doesn't fully close the issue
Place the reference at the end of the commit body:
```
feat: add person typeahead to document edit form
Closes #7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
```
## Code Style Reminders
- Pure functions over stateful helpers where possible
- No premature abstractions — solve the problem in front of you
- No backwards-compatibility shims for code that has no callers
- Validate at system boundaries only (user input, external APIs)

View File

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

378
backend/mvnw.cmd vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,47 +1,51 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/persons")
@RequiredArgsConstructor
public class PersonController {
private final PersonRepository personRepository;
private final DocumentRepository documentRepository;
private final PersonService personService;
private final DocumentService documentService;
@GetMapping
public ResponseEntity<List<Person>> getPersons(@RequestParam(required = false) String q) {
if (q != null && !q.isBlank()) {
return ResponseEntity.ok(personRepository.searchByName(q));
}
return ResponseEntity.ok(personRepository.findAllByOrderByLastNameAscFirstNameAsc());
return ResponseEntity.ok(personService.findAll(q));
}
@GetMapping("/{id}")
public Person getPerson(@PathVariable UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
return personService.getById(id);
}
@GetMapping("/{id}/documents")
public List<Document> getPersonDocuments(@PathVariable UUID id) {
return documentRepository.findBySenderId(id);
return documentService.getDocumentsBySender(id);
}
@PostMapping
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
String firstName = body.get("firstName");
String lastName = body.get("lastName");
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
return ResponseEntity.ok(personService.createPerson(firstName.trim(), lastName.trim(), body.get("alias")));
}
@PutMapping("/{id}")

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.HttpStatus;
@@ -8,7 +10,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
@@ -16,6 +18,45 @@ public class PersonService {
private final PersonRepository personRepository;
public List<Person> findAll(String q) {
if (q != null && !q.isBlank()) {
return personRepository.searchByName(q);
}
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
}
public Person getById(UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
}
public List<Person> getAllById(List<UUID> ids) {
return personRepository.findAllById(ids);
}
@Transactional
public Person findOrCreateByAlias(String rawName) {
String alias = rawName.trim();
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
PersonNameParser.SplitName split = PersonNameParser.split(alias);
return personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
});
}
@Transactional
public Person createPerson(String firstName, String lastName, String alias) {
Person person = Person.builder()
.firstName(firstName)
.lastName(lastName)
.alias(alias == null || alias.isBlank() ? null : alias.trim())
.build();
return personRepository.save(person);
}
@Transactional
public Person updatePerson(UUID id, String firstName, String lastName, String alias) {
Person person = personRepository.findById(id)

View File

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

View File

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

View File

View File

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

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

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

View File

@@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
/**
* Document management E2E tests.
* Assumes auth setup has run (storageState is applied by playwright.config.ts).
* Assumes the backend has at least one document in the database.
*/
test.describe('Document list', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('renders the search bar and document list', async ({ page }) => {
await expect(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toBeVisible();
await expect(page.getByRole('link', { name: /Neues Dokument/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-home.png' });
});
test('navigation bar shows active state for Dokumente', async ({ page }) => {
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' });
await expect(navLink).toHaveClass(/border-brand-navy/);
});
test('text search filters the document list', async ({ page }) => {
const input = page.getByPlaceholder('Suche in Titel, Inhalt, Ort...');
await input.fill('zzz_unlikely_to_match_anything');
// Wait for debounced navigation
await page.waitForURL(/\?q=/);
await expect(page.getByText('Keine Dokumente gefunden')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-search-no-results.png' });
});
test('clearing the search returns all documents', async ({ page }) => {
const input = page.getByPlaceholder('Suche in Titel, Inhalt, Ort...');
await input.fill('xyz_unlikely');
await page.waitForURL(/\?q=/);
// Click the reset link
await page.getByTitle('Filter zurücksetzen').click();
await page.waitForURL('/');
await expect(page).toHaveURL('/');
await page.screenshot({ path: 'test-results/e2e/documents-reset-search.png' });
});
test('advanced filters panel opens and closes', async ({ page }) => {
const btn = page.getByRole('button', { name: /Filter/i });
await btn.click();
await expect(page.getByLabel('Von')).toBeVisible();
await expect(page.getByLabel('Bis')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-filters-open.png' });
await btn.click();
await expect(page.getByLabel('Von')).not.toBeVisible();
});
test('date range filter triggers a new search', async ({ page }) => {
await page.getByRole('button', { name: /Filter/i }).click();
await page.getByLabel('Von').fill('2000-01-01');
await page.waitForURL(/from=2000-01-01/);
await expect(page).toHaveURL(/from=2000-01-01/);
await page.screenshot({ path: 'test-results/e2e/documents-date-filter.png' });
});
});
test.describe('Document detail', () => {
test('clicking a document opens the detail page', async ({ page }) => {
await page.goto('/');
// Click the first document link in the list
const firstDoc = page.locator('ul li a').first();
const href = await firstDoc.getAttribute('href');
await firstDoc.click();
await expect(page).toHaveURL(href!);
await page.screenshot({ path: 'test-results/e2e/document-detail.png' });
});
});
test.describe('New document', () => {
test('renders the upload form', async ({ page }) => {
await page.goto('/documents/new');
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible();
await expect(page.getByLabel('Titel')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-new.png' });
});
});
test.describe('Document edit', () => {
test('renders the edit form with pre-filled data', async ({ page }) => {
// Navigate to home, find first document, go to its edit page
await page.goto('/');
const firstDocLink = page.locator('ul li a').first();
const href = await firstDocLink.getAttribute('href');
await page.goto(`${href}/edit`);
await expect(page.getByRole('heading', { name: /Bearbeiten/i })).toBeVisible();
await expect(page.getByLabel('Titel')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit.png' });
});
test('shows a validation error for an invalid date format', async ({ page }) => {
await page.goto('/');
const firstDocLink = page.locator('ul li a').first();
const href = await firstDocLink.getAttribute('href');
await page.goto(`${href}/edit`);
const dateInput = page.getByLabel('Datum');
await dateInput.fill('invalid');
// Wait for the derived dateInvalid to trigger (needs user to type something that doesn't parse)
// Type a partial date to trigger dirty+invalid
await dateInput.fill('');
await dateInput.pressSequentially('abc');
await expect(page.getByText(/TT\.MM\.JJJJ/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
});
});

View File

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

View File

@@ -0,0 +1,97 @@
import { test, expect } from '@playwright/test';
test.describe('Person list', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/persons');
});
test('renders the persons list page', async ({ page }) => {
await expect(page.getByRole('heading', { name: /Personen/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/persons-list.png' });
});
test('search filters the persons list', async ({ page }) => {
const searchInput = page.getByPlaceholder(/Namen suchen/i);
await searchInput.fill('zzz_unlikely_match');
await page.waitForTimeout(600); // debounce
await expect(page.getByText(/Keine Personen gefunden/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/persons-search-empty.png' });
});
test('clicking a person opens the detail page', async ({ page }) => {
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
await expect(page).toHaveURL(/\/persons\/.+/);
await page.screenshot({ path: 'test-results/e2e/person-detail.png' });
});
});
test.describe('Person detail', () => {
test('shows the person name and their documents', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
// The detail page shows the person's name as a heading
await expect(page.getByRole('heading')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-detail-documents.png' });
});
test('can enter and cancel edit mode', async ({ page }) => {
await page.goto('/persons');
const firstPerson = page.locator('a[href^="/persons/"]').first();
await firstPerson.click();
// Click the edit button
const editBtn = page.getByRole('button', { name: /Bearbeiten/i });
if (await editBtn.isVisible()) {
await editBtn.click();
await expect(page.getByLabel('Vorname')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-edit-form.png' });
// Cancel
await page.getByRole('button', { name: /Abbrechen/i }).click();
await expect(page.getByLabel('Vorname')).not.toBeVisible();
}
});
});
test.describe('New person', () => {
test('renders the new person form', async ({ page }) => {
await page.goto('/persons/new');
await expect(page.getByLabel('Vorname')).toBeVisible();
await expect(page.getByLabel('Nachname')).toBeVisible();
await expect(page.getByRole('button', { name: /Erstellen/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/person-new.png' });
});
test('shows a validation error when submitting with empty fields', async ({ page }) => {
await page.goto('/persons/new');
// HTML required attribute prevents submission without filling required fields
await page.getByRole('button', { name: /Erstellen/i }).click();
// The form should not have navigated away
await expect(page).toHaveURL('/persons/new');
});
});
test.describe('Conversations', () => {
test('shows the empty state when no persons are selected', async ({ page }) => {
await page.goto('/conversations');
await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' });
});
test('nav link is active on the conversations page', async ({ page }) => {
await page.goto('/conversations');
const navLink = page.getByRole('link', { name: 'Konversationen' });
await expect(navLink).toHaveClass(/border-brand-navy/);
});
test('sort toggle changes the button label', async ({ page }) => {
await page.goto('/conversations');
const btn = page.getByRole('button', { name: /Sortierung/i });
await expect(btn).toContainText('Neueste zuerst');
await btn.click();
await expect(page).toHaveURL(/dir=ASC/);
await expect(btn).toContainText('Älteste zuerst');
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
});
});

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,15 +1,20 @@
<script lang="ts">
type Person = { id?: string; firstName?: string; lastName?: string; alias?: string };
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
export let selectedPersons: Person[] = [];
interface Props {
selectedPersons?: Person[];
}
let searchTerm = '';
let results: Person[] = [];
let showDropdown = false;
let loading = false;
let { 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 = '';
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
@@ -47,7 +52,7 @@
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !(e as Event).defaultPrevented) {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showDropdown = false;
}
};
@@ -56,7 +61,7 @@
}
</script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedPersons as person}
<input type="hidden" name="receiverIds" value={person.id} />
@@ -69,7 +74,7 @@
{person.firstName} {person.lastName}
<button
type="button"
on:click={() => removePerson(person.id)}
onclick={() => removePerson(person.id)}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
aria-label="Entfernen"
>
@@ -85,8 +90,8 @@
type="text"
autocomplete="off"
bind:value={searchTerm}
on:input={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder={selectedPersons.length === 0 ? 'Namen tippen...' : ''}
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
@@ -101,10 +106,10 @@
<div class="p-2 text-gray-500 text-sm">Suche...</div>
{:else}
{#each results as person}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
on:click={() => selectPerson(person)}
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>

View File

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

View File

@@ -1,30 +1,33 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
// Props
export let name: string;
export let label: string;
export let value: string = "";
export let initialName: string = "";
interface Props {
name: string;
label: string;
value?: string;
initialName?: string;
onchange?: (value: string) => void;
}
const dispatch = createEventDispatcher();
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
// Lokaler State
let searchTerm = initialName;
let searchTerm = $state('');
// Sync mit externen Änderungen (z.B. Reset Button)
$: searchTerm = initialName;
// Sync with external changes (e.g. reset button) — also sets the initial value
$effect(() => {
searchTerm = initialName;
});
let results: any[] = [];
let showDropdown = false;
let loading = false;
let debounceTimer: any;
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
function handleInput() {
// Wenn der User tippt, ist die alte ID ungültig -> Reset
if (value && searchTerm !== initialName) {
value = "";
dispatch('change', { value: "" }); // Bescheid geben: Auswahl aufgehoben
value = '';
onchange?.('');
}
showDropdown = true;
@@ -38,13 +41,9 @@
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
results = await res.json();
} else {
results = [];
}
results = res.ok ? await res.json() : [];
} catch (e) {
console.error("Suche fehlgeschlagen", e);
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
@@ -52,17 +51,15 @@
}, 300);
}
function selectPerson(person: any) {
value = person.id;
function selectPerson(person: Person) {
value = person.id!;
searchTerm = `${person.firstName} ${person.lastName}`;
showDropdown = false;
// --- NEU: Event feuern ---
dispatch('change', { value: person.id });
onchange?.(person.id!);
}
let inputEl: HTMLInputElement;
let dropdownStyle = '';
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
@@ -71,8 +68,8 @@
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: any) => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
showDropdown = false;
}
};
@@ -83,7 +80,7 @@
}
</script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
<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>
@@ -96,8 +93,8 @@
id="{name}-search"
autocomplete="off"
bind:value={searchTerm}
on:input={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder="Namen tippen..."
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
/>
@@ -111,10 +108,10 @@
<div class="p-2 text-gray-500 text-sm">Suche...</div>
{:else}
{#each results as person}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
on:click={() => selectPerson(person)}
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>

View File

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

View File

@@ -1,13 +1,16 @@
<script lang="ts">
export let tags: string[] = []; // Two-way binding
export let allowCreation = true;
interface Props {
tags?: string[];
allowCreation?: boolean;
}
let inputVal = '';
let suggestions: string[] = [];
let activeIndex = -1;
let showSuggestions = false;
let { tags = $bindable([]), allowCreation = true }: Props = $props();
let inputVal = $state('');
let suggestions: string[] = $state([]);
let activeIndex = $state(-1);
let showSuggestions = $state(false);
// Fetch suggestions from backend
async function fetchSuggestions(query: string) {
if (query.length < 2) {
suggestions = [];
@@ -17,13 +20,12 @@
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
// API returns Tag objects with { id, name }
const names: string[] = data.map((t: { name: string }) => t.name);
suggestions = names.filter((t) => !tags.includes(t));
showSuggestions = true;
}
} catch (e) {
console.error("Tag fetch error", e);
console.error('Tag fetch error', e);
}
}
@@ -47,8 +49,8 @@
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
addTag(suggestions[activeIndex]);
} else if(allowCreation) {
addTag(inputVal); // Add new tag
} else if (allowCreation) {
addTag(inputVal);
}
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
removeTag(tags.length - 1);
@@ -61,81 +63,86 @@
}
}
function handleInput() {
fetchSuggestions(inputVal);
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">
<!-- 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"
on:click={() => removeTag(i)}
aria-label="Schlagwort entfernen"
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}
<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="Schlagwort entfernen"
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}
<!-- Input Field -->
<div class="relative flex-1 min-w-[120px]">
<input
type="text"
bind:value={inputVal}
on:input={handleInput}
on:keydown={handleKeydown}
on:focus={() => handleInput()}
on:blur={() => setTimeout(() => (showSuggestions = false), 200)}
placeholder={tags.length === 0
? allowCreation
? 'Schlagworte hinzufügen...'
: 'Nach Schlagworten filtern...'
: ''}
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
<!-- 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
? allowCreation
? 'Schlagworte hinzufügen...'
: 'Nach Schlagworten filtern...'
: ''}
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
<!-- 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'
: 'text-gray-700'}"
on:click={() => addTag(suggestion)}
on:keydown={(e) => e.key === 'Enter' && addTag(suggestion)}
>
{suggestion}
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if allowCreation}
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
{/if}
<!-- 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'
: '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">Enter drücken um Schlagwort zu erstellen.</p>
{/if}
</div>

View File

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

View File

@@ -68,6 +68,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getPersons"];
put?: never;
post: operations["createPerson"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/merge": {
parameters: {
query?: never;
@@ -180,22 +196,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/persons": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getPersons"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/documents": {
parameters: {
query?: never;
@@ -581,6 +581,54 @@ export interface operations {
};
};
};
getPersons: {
parameters: {
query?: {
q?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"][];
};
};
};
};
createPerson: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
[key: string]: string;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"];
};
};
};
};
mergePerson: {
parameters: {
query?: never;
@@ -783,28 +831,6 @@ export interface operations {
};
};
};
getPersons: {
parameters: {
query?: {
q?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"][];
};
};
};
};
getPersonDocuments: {
parameters: {
query?: never;

View File

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

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

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

View File

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

View File

@@ -3,21 +3,19 @@ import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { goto } from '$app/navigation';
import TagInput from '$lib/components/TagInput.svelte';
import { slide } from 'svelte/transition';
import { untrack } from 'svelte';
export let data;
let { data } = $props();
// Local state variables
let q = data.filters?.q || '';
let from = data.filters?.from || '';
let to = data.filters?.to || '';
let senderId = data.filters?.senderId || '';
let receiverId = data.filters?.receiverId || '';
let tagNames = data.filters?.tags || [];
let q = $state(untrack(() => data.filters?.q || ''));
let from = $state(untrack(() => data.filters?.from || ''));
let to = $state(untrack(() => data.filters?.to || ''));
let senderId = $state(untrack(() => data.filters?.senderId || ''));
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
// Debounce Timer
let searchTimer: any;
let showAdvanced = false;
let searchTimer: ReturnType<typeof setTimeout>;
let showAdvanced = $state(false);
function triggerSearch() {
const params = new URLSearchParams();
@@ -42,27 +40,24 @@ function handleTextSearch() {
}, 500);
}
let previousTags = tagNames.join(',');
$: {
const currentTags = tagNames.join(',');
if (currentTags !== previousTags) {
previousTags = currentTags;
// Trigger search when tags change
let prevTagStr = untrack(() => tagNames.join(','));
$effect(() => {
const cur = tagNames.join(',');
if (cur !== prevTagStr) {
prevTagStr = cur;
triggerSearch();
}
}
});
function toggleAdvanced() {
showAdvanced = !showAdvanced;
}
// Sync with server data (e.g. after reset)
$: {
// Sync local state with server data after navigation
$effect(() => {
q = data.filters?.q || '';
from = data.filters?.from || '';
to = data.filters?.to || '';
senderId = data.filters?.senderId || '';
receiverId = data.filters?.receiverId || '';
}
});
</script>
<!-- Outer Container: Matches the 'Sand' background of the layout -->
@@ -76,7 +71,7 @@ $: {
<input
type="text"
bind:value={q}
on:input={handleTextSearch}
oninput={handleTextSearch}
placeholder="Suche in Titel, Inhalt, Ort..."
class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
@@ -94,7 +89,7 @@ $: {
<!-- Toggle Advanced Button -->
<button
on:click={toggleAdvanced}
onclick={() => (showAdvanced = !showAdvanced)}
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
>
<svg
@@ -143,7 +138,7 @@ $: {
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
Schlagworte
</p>
<TagInput bind:tags={tagNames} allowCreation={false} on:change={triggerSearch} />
<TagInput bind:tags={tagNames} allowCreation={false} />
</div>
<!-- Sender -->
@@ -156,7 +151,7 @@ $: {
label="Absender"
bind:value={senderId}
initialName={data.initialValues?.senderName}
on:change={triggerSearch}
onchange={triggerSearch}
/>
</div>
</div>
@@ -171,7 +166,7 @@ $: {
label="Empfänger"
bind:value={receiverId}
initialName={data.initialValues?.receiverName}
on:change={triggerSearch}
onchange={triggerSearch}
/>
</div>
</div>
@@ -188,7 +183,7 @@ $: {
type="date"
id="from"
bind:value={from}
on:change={triggerSearch}
onchange={triggerSearch}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
/>
</div>
@@ -202,7 +197,7 @@ $: {
type="date"
id="to"
bind:value={to}
on:change={triggerSearch}
onchange={triggerSearch}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
/>
</div>
@@ -329,15 +324,14 @@ $: {
</div>
</div>
<!-- NEW: Tags Display -->
<!-- Tags Display -->
{#if doc.tags && doc.tags.length > 0}
<div class="mt-4 flex flex-wrap gap-2 pt-3">
{#each doc.tags as tag}
<button
type="button"
class="relative z-10 inline-flex items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
on:click|preventDefault|stopPropagation={() =>
goto(`/?tag=${encodeURIComponent(tag.name)}`)}
onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
>
{tag.name}
</button>
@@ -384,7 +378,7 @@ $: {
Versuchen Sie, die Filter anzupassen oder den Suchbegriff zu ändern.
</p>
<button
on:click={() => goto('/')}
onclick={() => goto('/')}
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
>
Alle Filter löschen

View File

@@ -2,15 +2,17 @@
import { enhance } from '$app/forms';
import { slide } from 'svelte/transition';
export let data;
export let form;
let { data, form } = $props();
let activeTab = 'users';
let editingTagId: string | null = null;
let editingTagName = '';
let editingUserId: string | null = null;
let activeTab = $state('users');
let editingTagId: string | null = $state(null);
let editingTagName = $state('');
let editingUserId: string | null = $state(null);
let editingGroupId: string | null = $state(null);
function startEditTag(tag: any) {
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
function startEditTag(tag: { id: string; name: string }) {
editingTagId = tag.id;
editingTagName = tag.name;
}
@@ -20,7 +22,7 @@
editingTagName = '';
}
function startEditUser(id: string) {
function startEditUser(id: string) {
editingUserId = id;
}
@@ -28,9 +30,6 @@
editingUserId = null;
}
let editingGroupId: string | null = null;
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
function startEditGroup(id: string) {
editingGroupId = id;
}
@@ -51,21 +50,21 @@
'users'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'users')}>Benutzer</button
onclick={() => (activeTab = 'users')}>Benutzer</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'groups'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'groups')}>Gruppen</button
onclick={() => (activeTab = 'groups')}>Gruppen</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'tags'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'tags')}>Schlagworte</button
onclick={() => (activeTab = 'tags')}>Schlagworte</button
>
</div>
</div>
@@ -106,7 +105,6 @@
<!-- === EDIT MODE === -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.username}
<!-- Hidden ID Input for the form -->
<input
type="hidden"
name="username"
@@ -116,7 +114,6 @@
</td>
<td class="px-6 py-4 text-sm">
<!-- Groups Select -->
<select
name="groupIds"
multiple
@@ -126,7 +123,7 @@
{#each data.groups as group}
<option
value={group.id}
selected={user.groups.some((g) => g.id === group.id)}
selected={user.groups.some((g: { id: string }) => g.id === group.id)}
>
{group.name}
</option>
@@ -136,7 +133,6 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
<!-- Password & Buttons -->
<form
id="edit-form-{user.id}"
method="POST"
@@ -164,7 +160,7 @@
</button>
<button
type="button"
on:click={cancelEditUser}
onclick={cancelEditUser}
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
>
Abbrechen
@@ -194,15 +190,13 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-4">
<!-- Edit Button -->
<button
on:click={() => startEditUser(user.id)}
onclick={() => startEditUser(user.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
>
Bearbeiten
</button>
<!-- Delete Button -->
<form
method="POST"
action="?/deleteUser"
@@ -265,7 +259,6 @@
class="rounded border-gray-300 text-sm w-full"
/>
<!-- Multi-Select for Groups -->
<div class="md:col-span-3">
<select
name="groupIds"
@@ -290,7 +283,6 @@
</div>
</div>
{:else if activeTab === 'tags'}
<!-- TAGS SECTION (unchanged logic, just ensuring style consistency) -->
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 bg-yellow-50/50">
<h2 class="text-lg font-bold text-gray-700">Schlagworte</h2>
@@ -332,9 +324,9 @@
>
<button
type="button"
on:click={cancelEditTag}
onclick={cancelEditTag}
aria-label="Abbrechen"
class="text-gray-400 hover:text-gray-600"
class="text-gray-400 hover:text-gray-600"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
@@ -353,7 +345,7 @@
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
on:click={() => startEditTag(tag)}
onclick={() => startEditTag(tag)}
aria-label="Schlagwort bearbeiten"
class="p-1 text-gray-400 hover:text-brand-navy"
>
@@ -370,16 +362,13 @@
method="POST"
action="?/deleteTag"
use:enhance={({ cancel }) => {
// This runs BEFORE the request is sent
if (
!confirm(
'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.'
)
) {
cancel(); // Stop the request
cancel();
}
// This runs AFTER the server responds
return async ({ update }) => {
await update();
};
@@ -443,7 +432,6 @@
>
<input type="hidden" name="id" value={group.id} />
<!-- Name Input -->
<div class="w-full sm:w-1/3">
<input
type="text"
@@ -454,7 +442,6 @@
/>
</div>
<!-- Permissions Checkboxes -->
<div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2">
{#each availablePermissions as perm}
<label
@@ -472,7 +459,6 @@
{/each}
</div>
<!-- Actions -->
<div class="flex gap-2 self-start sm:self-center">
<button type="submit" aria-label="Speichern" class="text-green-600 hover:text-green-800 p-1">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
@@ -486,9 +472,9 @@
</button>
<button
type="button"
on:click={cancelEditGroup}
onclick={cancelEditGroup}
aria-label="Abbrechen"
class="text-gray-400 hover:text-red-500 p-1"
class="text-gray-400 hover:text-red-500 p-1"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
@@ -524,7 +510,7 @@
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-3">
<button
on:click={() => startEditGroup(group.id)}
onclick={() => startEditGroup(group.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
>
Bearbeiten
@@ -537,7 +523,6 @@
if (!confirm('Gruppe wirklich löschen?')) {
cancel();
}
return async ({ update }) => {
await update();
};

View File

@@ -1,58 +1,39 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { untrack } from 'svelte';
export let data;
let { data } = $props();
// Data & State
let documents: typeof data.documents = [];
let initialValues = { senderName: '', receiverName: '' };
let senderId = $state(untrack(() => data.filters.senderId));
let receiverId = $state(untrack(() => data.filters.receiverId));
let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
// Filter State
let senderId = '';
let receiverId = '';
let fromDate = '';
let toDate = '';
let sortDir = 'DESC';
// Reactive Update
$: {
documents = data.documents;
initialValues = data.initialValues;
// Sync with server data after navigation
$effect(() => {
senderId = data.filters.senderId;
receiverId = data.filters.receiverId;
fromDate = data.filters.from;
toDate = data.filters.to;
sortDir = data.filters.dir;
}
});
// Filter Logic
function applyFilters() {
setTimeout(() => {
const params = new URLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`?${params.toString()}`, { keepFocus: true });
}, 0);
const params = new URLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`?${params.toString()}`, { keepFocus: true });
}
function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
function handleSenderChange(event: CustomEvent) {
senderId = event.detail.value;
applyFilters();
}
function handleReceiverChange(event: CustomEvent) {
receiverId = event.detail.value;
applyFilters();
}
</script>
<div class="max-w-5xl mx-auto py-10 px-4">
@@ -74,9 +55,9 @@
<PersonTypeahead
name="senderId"
label="Person A (Absender)"
value={senderId}
initialName={initialValues.senderName}
on:change={handleSenderChange}
bind:value={senderId}
initialName={data.initialValues.senderName}
onchange={() => applyFilters()}
/>
</div>
@@ -87,9 +68,9 @@
<PersonTypeahead
name="receiverId"
label="Person B (Empfänger)"
value={receiverId}
initialName={initialValues.receiverName}
on:change={handleReceiverChange}
bind:value={receiverId}
initialName={data.initialValues.receiverName}
onchange={() => applyFilters()}
/>
</div>
</div>
@@ -106,7 +87,7 @@
id="dateFrom"
type="date"
bind:value={fromDate}
on:change={() => applyFilters()}
onchange={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
/>
</div>
@@ -122,7 +103,7 @@
id="dateTo"
type="date"
bind:value={toDate}
on:change={() => applyFilters()}
onchange={() => applyFilters()}
class="block w-full border-gray-300 shadow-sm focus:border-brand-navy focus:ring-brand-navy text-sm py-2.5"
/>
</div>
@@ -130,7 +111,7 @@
<!-- Sort Toggle -->
<div>
<button
on:click={toggleSort}
onclick={toggleSort}
class="w-full flex items-center justify-center h-[42px] border border-brand-sand text-xs font-bold uppercase tracking-wide text-brand-navy hover:bg-brand-navy hover:text-white transition-colors"
>
<span class="mr-2">Sortierung:</span>
@@ -169,7 +150,7 @@
<p class="text-brand-navy font-serif text-lg">Wählen Sie zwei Personen aus</p>
<p class="text-gray-500 font-sans text-sm mt-1">Die Korrespondenz wird hier angezeigt.</p>
</div>
{:else if documents.length === 0}
{:else if data.documents.length === 0}
<div
class="flex flex-col items-center justify-center py-24 bg-white border border-brand-sand rounded-sm text-center shadow-sm"
>
@@ -178,18 +159,15 @@
</div>
{:else}
<!-- CHAT CONTAINER -->
<!-- Added: White background, Border, Shadow to separate from page -->
<div class="bg-white border border-brand-sand shadow-sm rounded-sm relative overflow-hidden">
<!-- Decoration: Central Timeline Line -->
<div
class="absolute left-1/2 top-0 bottom-0 w-px bg-brand-sand/30 transform -translate-x-1/2 hidden md:block"
></div>
<!-- Scrollable Area (optional, if you want max-height) -->
<div class="p-6 md:p-8">
<!-- TIGHTER GAP: Changed from gap-8 to gap-4 -->
<div class="flex flex-col gap-4 relative z-10">
{#each documents as doc}
{#each data.documents as doc}
{@const isRight = doc.sender?.id === senderId}
<!-- Message Row -->
@@ -200,7 +178,7 @@
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR (Small) -->
<!-- AVATAR -->
<div class="flex-shrink-0 mt-auto mb-1 hidden sm:block">
<div
class="w-8 h-8 rounded-full flex items-center justify-center font-serif text-xs border shadow-sm
@@ -217,7 +195,6 @@
</div>
<!-- BUBBLE CARD -->
<!-- Adjusted padding (p-4) and added light bg to left bubbles for contrast -->
<a
href="/documents/{doc.id}"
class="group block p-4 rounded shadow-sm transition-all duration-200 transform hover:-translate-y-0.5 hover:shadow-md border

View File

@@ -1,24 +1,24 @@
<script lang="ts">
export let data;
$: doc = data.document;
let { data } = $props();
// Instead of a direct link, we use a reactive variable for the Blob URL
let fileUrl = '';
let isLoading = false;
let error = '';
const doc = $derived(data.document);
// Reactive statement: Whenever the document ID changes, load the file
$: if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
let fileUrl = $state('');
let isLoading = $state(false);
let error = $state('');
$effect(() => {
if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
});
async function loadFile(id: string) {
isLoading = true;
error = '';
fileUrl = ''; // Reset previous URL
fileUrl = '';
try {
// 1. Fetch with current authentication
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) {
@@ -26,10 +26,7 @@
throw new Error('Fehler beim Laden der Datei');
}
// 2. Create a Blob from the data
const blob = await response.blob();
// 3. Create a temporary URL for this Blob
fileUrl = URL.createObjectURL(blob);
} catch (e) {
@@ -186,7 +183,6 @@
{#if doc.documentLocation}
<div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5">
<!-- Archive Box Icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
@@ -209,7 +205,6 @@
{#if doc.tags && doc.tags.length > 0}
<div class="flex items-start group">
<span class="text-brand-mint w-8 mt-0.5">
<!-- Tag Icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
@@ -324,7 +319,7 @@
</div>
</div>
<!-- 3. INHALT GROUP (Merged Summary & Transcription) -->
<!-- 3. INHALT GROUP -->
{#if doc.summary || doc.transcription}
<div>
<h3
@@ -334,12 +329,9 @@
</h3>
<div class="space-y-6">
<!-- Summary Sub-Section -->
{#if doc.summary}
<div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase"
>Zusammenfassung</span
>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">Zusammenfassung</span>
<div
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
>
@@ -348,12 +340,9 @@
</div>
{/if}
<!-- Transcription Sub-Section -->
{#if doc.transcription}
<div>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase"
>Transkription</span
>
<span class="text-xs font-sans text-gray-400 block mb-2 uppercase">Transkription</span>
<div
class="bg-brand-sand/30 p-5 rounded border border-brand-sand text-sm font-serif text-brand-navy leading-relaxed whitespace-pre-wrap"
>
@@ -376,7 +365,6 @@
<!-- RIGHT: PREVIEW AREA -->
<main class="flex-1 bg-[#2A2A2A] relative flex flex-col items-center justify-center">
{#if isLoading}
<!-- Loading Spinner -->
<div class="text-brand-mint flex flex-col items-center">
<svg
class="animate-spin h-8 w-8 mb-4"
@@ -398,7 +386,6 @@
<div class="text-gray-400 text-center px-4">
<p class="font-serif mb-2">{error}</p>
{#if doc.filePath}
<!-- Direct link as fallback -->
<a
href={`/api/documents/${doc.id}/file`}
target="_blank"
@@ -409,10 +396,8 @@
{/if}
</div>
{:else if !doc.filePath}
<!-- No File State -->
<div class="flex flex-col items-center text-gray-400">
<div class="bg-white/5 p-8 rounded-full mb-6">
<!-- Icon... -->
<svg class="w-12 h-12 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
@@ -425,14 +410,12 @@
<p class="font-sans text-sm tracking-wide uppercase">Kein Scan vorhanden</p>
</div>
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
<!-- PDF Iframe with Blob URL -->
<iframe
src={fileUrl}
title="Document Preview"
class="w-full h-full border-none bg-white"
></iframe>
{:else if fileUrl}
<!-- Image with Blob URL -->
<div class="w-full h-full flex items-center justify-center overflow-auto p-8">
<img
src={fileUrl}

View File

@@ -3,31 +3,21 @@
import TagInput from '$lib/components/TagInput.svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import { untrack } from 'svelte';
import { isoToGerman, germanToIso } from '$lib/utils';
export let data;
export let form;
let { data, form } = $props();
let { document: doc } = data;
let tags = doc.tags ? doc.tags.map((t: any) => t.name) : [];
let senderId = doc.sender?.id ?? '';
let selectedReceivers = doc.receivers ?? [];
let { document: doc } = untrack(() => data);
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
let senderId = $state(doc.sender?.id ?? '');
let selectedReceivers = $state(doc.receivers ?? []);
function isoToGerman(iso: string): string {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
}
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
let dateIso = $state(doc.documentDate ?? '');
let dateDirty = $state(false);
function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
const [, d, m, y] = match;
return `${y}-${m}-${d}`;
}
let dateDisplay = isoToGerman(doc.documentDate ?? '');
let dateIso = doc.documentDate ?? '';
let dateDirty = false;
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function handleDateInput(e: Event) {
const input = e.target as HTMLInputElement;
@@ -45,8 +35,6 @@
dateIso = germanToIso(formatted);
dateDirty = true;
}
$: dateInvalid = dateDirty && dateDisplay.length > 0 && dateIso === '';
</script>
<div class="max-w-4xl mx-auto py-8 px-4">
@@ -84,7 +72,7 @@
type="text"
inputmode="numeric"
value={dateDisplay}
on:input={handleDateInput}
oninput={handleDateInput}
placeholder="TT.MM.JJJJ"
maxlength="10"
class="block w-full rounded border-gray-300 shadow-sm p-2 border text-sm

View File

@@ -4,15 +4,17 @@ import TagInput from '$lib/components/TagInput.svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
export let form;
let { form } = $props();
let tags: string[] = [];
let senderId = '';
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = [];
let tags: string[] = $state([]);
let senderId = $state('');
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state([]);
let dateDisplay = '';
let dateIso = '';
let dateDirty = false;
let dateDisplay = $state('');
let dateIso = $state('');
let dateDirty = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
@@ -37,8 +39,6 @@ function handleDateInput(e: Event) {
dateIso = germanToIso(formatted);
dateDirty = true;
}
$: dateInvalid = dateDirty && dateDisplay.length > 0 && dateIso === '';
</script>
<div class="mx-auto max-w-4xl px-4 py-8">
@@ -86,7 +86,7 @@ $: dateInvalid = dateDirty && dateDisplay.length > 0 && dateIso === '';
type="text"
inputmode="numeric"
value={dateDisplay}
on:input={handleDateInput}
oninput={handleDateInput}
placeholder="TT.MM.JJJJ"
maxlength="10"
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm

View File

@@ -1,6 +1,5 @@
<script lang="ts">
// TypeScript Typen für die Form-Antwort
export let form: { error?: string, success?: boolean };
let { form }: { form?: { error?: string; success?: boolean } } = $props();
</script>
<div class="min-h-screen flex items-center justify-center">
@@ -10,13 +9,13 @@
<form method="POST" action="?/login" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Benutzername</label>
<input type="text" name="username" id="username" required
<input type="text" name="username" id="username" required
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Passwort</label>
<input type="password" name="password" id="password" required
<input type="password" name="password" id="password" required
class="mt-1 block w-full rounded border-gray-300 shadow-sm p-2 border" />
</div>
@@ -24,7 +23,7 @@
<div class="text-red-600 text-sm text-center">{form.error}</div>
{/if}
<button type="submit"
<button type="submit"
class="bg-brand-navy text-white h-[42px] rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full">
Anmelden
</button>

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import LoginPage from './+page.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0));
describe('Login page rendering', () => {
it('renders the page title', async () => {
render(LoginPage, {});
await expect.element(page.getByText('Familienarchiv')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/login-default.png' });
});
it('renders the submit button', async () => {
render(LoginPage, {});
await expect.element(page.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument();
});
it('renders the username input', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="username"]');
expect(input).not.toBeNull();
});
it('renders the password input', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
expect(input).not.toBeNull();
});
it('username field is required', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="username"]');
expect(input?.required).toBe(true);
});
it('password field is required', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
expect(input?.required).toBe(true);
});
it('password field has type="password"', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
expect(input?.type).toBe('password');
});
it('form submits to the login action', async () => {
render(LoginPage, {});
await tick();
const form = document.querySelector<HTMLFormElement>('form');
expect(form?.action).toMatch(/\?\/login$/);
});
});
describe('Login page error state', () => {
it('shows no error when form is undefined', async () => {
render(LoginPage, {});
await tick();
expect(document.querySelector('.text-red-600')).toBeNull();
});
it('shows no error when form has no error property', async () => {
render(LoginPage, { form: {} });
await tick();
expect(document.querySelector('.text-red-600')).toBeNull();
});
it('displays the error message from the form action', async () => {
render(LoginPage, { form: { error: 'Ungültige Anmeldedaten.' } });
await expect.element(page.getByText('Ungültige Anmeldedaten.')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/login-error.png' });
});
it('applies red styling to the error text', async () => {
render(LoginPage, { form: { error: 'Fehler!' } });
await tick();
expect(document.querySelector('.text-red-600')).not.toBeNull();
});
});

View File

@@ -1,13 +1,172 @@
import { page } from 'vitest/browser';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
const tick = () => new Promise((r) => setTimeout(r, 0));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Silence fetch calls from PersonTypeahead when advanced filters are open
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
// ─── Test data ────────────────────────────────────────────────────────────────
const emptyData = {
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
documents: [],
initialValues: { senderName: '', receiverName: '' },
error: null
};
const makeDoc = (overrides = {}) => ({
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED',
documentDate: '2024-03-15',
location: 'Berlin',
sender: { id: 'p1', firstName: 'Max', lastName: 'Mustermann' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Musterfrau' }],
tags: [{ name: 'Familie' }],
filePath: '/files/testbrief.pdf',
...overrides
});
const dataWithDocs = { ...emptyData, documents: [makeDoc()] };
// ─── Search bar ───────────────────────────────────────────────────────────────
describe('Home page search bar', () => {
it('renders the full-text search input', async () => {
render(Page, { data: emptyData });
await expect
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/home-default.png' });
});
it('renders the filter toggle button', async () => {
render(Page, { data: emptyData });
// Use exact match to avoid collision with the empty-state "Alle Filter löschen" button
await expect
.element(page.getByRole('button', { name: 'Filter', exact: true }))
.toBeInTheDocument();
});
it('renders the reset link pointing to /', async () => {
render(Page, { data: emptyData });
const resetLink = page.getByTitle('Filter zurücksetzen');
await expect.element(resetLink).toBeInTheDocument();
await expect.element(resetLink).toHaveAttribute('href', '/');
});
it('pre-fills the search input from filters.q', async () => {
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
await expect.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toHaveValue('Urlaub');
});
});
// ─── Advanced filters ─────────────────────────────────────────────────────────
describe('Home page advanced filters', () => {
it('hides the advanced filters by default', async () => {
render(Page, { data: emptyData });
// Date inputs are inside the {#if showAdvanced} block → not in DOM
await tick();
expect(document.querySelector('input[id="from"]')).toBeNull();
expect(document.querySelector('input[id="to"]')).toBeNull();
});
it('toggles the advanced filter panel open on button click', async () => {
render(Page, { data: emptyData });
await page.getByRole('button', { name: 'Filter', exact: true }).click();
await tick();
expect(document.querySelector('input[id="from"]')).not.toBeNull();
expect(document.querySelector('input[id="to"]')).not.toBeNull();
await page.screenshot({ path: 'test-results/screenshots/home-filters-open.png' });
});
it('collapses the advanced filter panel on second click', async () => {
render(Page, { data: emptyData });
const btn = page.getByRole('button', { name: 'Filter', exact: true });
await btn.click();
// Wait for the input to appear before clicking again
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
await btn.click();
// Wait for slide transition to finish
await expect.element(page.getByText('Schlagworte')).not.toBeInTheDocument();
});
it('renders the tag filter section when filters are open', async () => {
render(Page, { data: emptyData });
await page.getByRole('button', { name: 'Filter', exact: true }).click();
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
});
});
// ─── Document list ────────────────────────────────────────────────────────────
describe('Home page document list', () => {
it('shows empty state when there are no documents', async () => {
render(Page, { data: emptyData });
await expect.element(page.getByText('Keine Dokumente gefunden')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/home-empty-state.png' });
});
it('renders a document with title, date, location, sender and receiver', async () => {
render(Page, { data: dataWithDocs });
await expect.element(page.getByText('Testbrief')).toBeInTheDocument();
await expect.element(page.getByText('15. März 2024')).toBeInTheDocument();
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/home-with-documents.png' });
});
it('renders a tag chip for each document tag', async () => {
render(Page, { data: dataWithDocs });
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
});
it('renders "Unbekannt" for sender when sender is null', async () => {
const data = { ...emptyData, documents: [makeDoc({ sender: null })] };
render(Page, { data });
await expect.element(page.getByText('Unbekannt')).toBeInTheDocument();
});
it('renders original filename when title is empty', async () => {
const data = { ...emptyData, documents: [makeDoc({ title: null })] };
render(Page, { data });
await expect.element(page.getByText('testbrief.pdf')).toBeInTheDocument();
});
it('links each document to its detail page', async () => {
render(Page, { data: dataWithDocs });
const link = page.getByRole('link', { name: /Testbrief/ });
await expect.element(link).toHaveAttribute('href', '/documents/1');
});
it('renders the "Neues Dokument" link', async () => {
render(Page, { data: emptyData });
const link = page.getByRole('link', { name: /Neues Dokument/i });
await expect.element(link).toBeInTheDocument();
await expect.element(link).toHaveAttribute('href', '/documents/new');
});
});
// ─── Error state ──────────────────────────────────────────────────────────────
describe('Home page error state', () => {
it('shows the error message when data.error is set', async () => {
const data = { ...emptyData, error: 'Daten konnten nicht geladen werden.' };
render(Page, { data });
await expect
.element(page.getByText('Daten konnten nicht geladen werden.'))
.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
});
});

View File

@@ -1,84 +1,128 @@
<script lang="ts">
import { goto } from '$app/navigation';
export let data;
import { goto } from '$app/navigation';
let searchTimeout: any;
let { data } = $props();
// Live-Suche (Debounce)
function handleSearch(e: Event) {
const value = (e.target as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
goto(`/persons?q=${value}`, { keepFocus: true });
}, 300);
}
let searchTimeout: ReturnType<typeof setTimeout>;
function handleSearch(e: Event) {
const value = (e.target as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
goto(`/persons?q=${value}`, { keepFocus: true });
}, 300);
}
</script>
<div class="max-w-7xl mx-auto py-12 sm:px-6 lg:px-8">
<!-- Header Area -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-10 border-b border-brand-navy/10 pb-6">
<div>
<h1 class="text-3xl font-serif font-medium text-brand-navy">Personenverzeichnis</h1>
<p class="text-brand-navy/60 font-sans text-sm mt-2 max-w-xl">
Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.
</p>
</div>
<div class="mx-auto max-w-7xl py-12 sm:px-6 lg:px-8">
<!-- Header Area -->
<div
class="mb-10 flex flex-col justify-between gap-6 border-b border-brand-navy/10 pb-6 md:flex-row md:items-end"
>
<div>
<h1 class="font-serif text-3xl font-medium text-brand-navy">Personenverzeichnis</h1>
<p class="mt-2 max-w-xl font-sans text-sm text-brand-navy/60">
Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.
</p>
<a
href="/persons/new"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Neue Person
</a>
</div>
<!-- Search Input -->
<div class="w-full md:w-80">
<label for="search" class="sr-only">Suche</label>
<div class="relative">
<input
id="search"
type="text"
placeholder="Namen suchen..."
value={data.q || ''}
on:input={handleSearch}
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 text-sm font-sans text-brand-navy shadow-sm placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
/>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-gray-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
</div>
</div>
</div>
<!-- Search Input -->
<div class="w-full md:w-80">
<label for="search" class="sr-only">Suche</label>
<div class="relative">
<input
id="search"
type="text"
placeholder="Namen suchen..."
value={data.q || ''}
oninput={handleSearch}
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/></svg
>
</div>
</div>
</div>
</div>
{#if data.persons.length === 0}
<div class="flex flex-col items-center justify-center py-16 bg-white border border-brand-sand border-dashed rounded-lg text-center">
<div class="w-12 h-12 bg-brand-sand/30 rounded-full flex items-center justify-center mb-3 text-brand-navy">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</div>
<p class="text-brand-navy font-serif text-lg">Keine Personen gefunden.</p>
<p class="text-gray-500 font-sans text-sm mt-1">Versuchen Sie einen anderen Suchbegriff.</p>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{#each data.persons as person}
<a href="/persons/{person.id}" class="block group h-full">
<div class="h-full bg-white rounded shadow-sm border border-brand-sand p-6 flex items-center gap-4 hover:border-brand-navy hover:shadow-md transition-all duration-200 relative overflow-hidden">
{#if data.persons.length === 0}
<div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-brand-sand bg-white py-16 text-center"
>
<div
class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30 text-brand-navy"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/></svg
>
</div>
<p class="font-serif text-lg text-brand-navy">Keine Personen gefunden.</p>
<p class="mt-1 font-sans text-sm text-gray-500">Versuchen Sie einen anderen Suchbegriff.</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each data.persons as person}
<a href="/persons/{person.id}" class="group block h-full">
<div
class="relative flex h-full items-center gap-4 overflow-hidden rounded border border-brand-sand bg-white p-6 shadow-sm transition-all duration-200 hover:border-brand-navy hover:shadow-md"
>
<!-- Decorative Accent on Hover -->
<div
class="absolute top-0 bottom-0 left-0 w-1 bg-brand-navy opacity-0 transition-opacity group-hover:opacity-100"
></div>
<!-- Decorative Accent on Hover -->
<div class="absolute left-0 top-0 bottom-0 w-1 bg-brand-navy opacity-0 group-hover:opacity-100 transition-opacity"></div>
<!-- Avatar -->
<div class="flex-shrink-0">
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-navy font-serif text-lg text-white transition-colors group-hover:bg-brand-mint group-hover:text-brand-navy"
>
{person.firstName[0]}{person.lastName[0]}
</div>
</div>
<!-- Avatar -->
<div class="flex-shrink-0">
<div class="h-12 w-12 rounded-full bg-brand-navy text-white flex items-center justify-center font-serif text-lg group-hover:bg-brand-mint group-hover:text-brand-navy transition-colors">
{person.firstName[0]}{person.lastName[0]}
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="text-base font-serif font-medium text-brand-navy truncate group-hover:underline decoration-brand-mint decoration-2 underline-offset-2">
{person.firstName} {person.lastName}
</p>
{#if person.alias}
<p class="text-xs font-sans text-gray-500 truncate mt-0.5">"{person.alias}"</p>
{/if}
</div>
</div>
</a>
{/each}
</div>
{/if}
<!-- Info -->
<div class="min-w-0 flex-1">
<p
class="truncate font-serif text-base font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
>
{person.firstName}
{person.lastName}
</p>
{#if person.alias}
<p class="mt-0.5 truncate font-sans text-xs text-gray-500">"{person.alias}"</p>
{/if}
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>

View File

@@ -2,21 +2,25 @@
import { enhance } from '$app/forms';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
export let data;
export let form;
let { data, form } = $props();
$: ({ person, documents } = data);
const person = $derived(data.person);
const documents = $derived(data.documents);
let editMode = false;
let mergeTargetId = '';
let showMergeConfirm = false;
let editMode = $state(false);
let mergeTargetId = $state('');
let showMergeConfirm = $state(false);
function enterEdit() { editMode = true; }
function cancelEdit() { editMode = false; }
$effect(() => {
if (form?.updated) editMode = false;
});
$: if (form?.updated) { editMode = false; }
$: person.id, (() => { mergeTargetId = ''; showMergeConfirm = false; })();
$effect(() => {
// Reset merge state whenever person changes
person.id;
mergeTargetId = '';
showMergeConfirm = false;
});
</script>
<div class="max-w-4xl mx-auto py-10 px-4">
@@ -83,7 +87,7 @@
<button type="submit" class="px-5 py-2 bg-brand-navy text-white text-sm font-bold uppercase tracking-widest rounded hover:bg-brand-navy/80 transition-colors">
Speichern
</button>
<button type="button" on:click={cancelEdit} class="px-5 py-2 border border-gray-300 text-gray-600 text-sm font-bold uppercase tracking-widest rounded hover:bg-gray-50 transition-colors">
<button type="button" onclick={() => (editMode = false)} class="px-5 py-2 border border-gray-300 text-gray-600 text-sm font-bold uppercase tracking-widest rounded hover:bg-gray-50 transition-colors">
Abbrechen
</button>
</div>
@@ -103,7 +107,7 @@
<h1 class="text-4xl font-serif text-brand-navy">
{person.firstName} {person.lastName}
</h1>
<button on:click={enterEdit} class="ml-4 flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
<button onclick={() => (editMode = true)} class="ml-4 flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:border-brand-navy hover:text-brand-navy transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Bearbeiten
</button>
@@ -150,7 +154,7 @@
name="_targetPersonDisplay"
label="Zusammenführen mit"
value={mergeTargetId}
on:change={(e) => { mergeTargetId = e.detail.value; showMergeConfirm = false; }}
onchange={(value) => { mergeTargetId = value; showMergeConfirm = false; }}
/>
</div>
@@ -158,7 +162,7 @@
<button
type="button"
disabled={!mergeTargetId}
on:click={() => showMergeConfirm = true}
onclick={() => (showMergeConfirm = true)}
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-red-300 text-red-600 rounded hover:bg-red-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Zusammenführen
@@ -173,7 +177,7 @@
</button>
<button
type="button"
on:click={() => showMergeConfirm = false}
onclick={() => (showMergeConfirm = false)}
class="px-4 py-2 text-sm font-bold uppercase tracking-widest border border-gray-300 text-gray-500 rounded hover:bg-gray-50 transition-colors"
>
Abbrechen

View File

@@ -0,0 +1,26 @@
import { fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
if (!firstName || !lastName) {
return fail(400, { error: 'Vor- und Nachname sind Pflichtfelder.' });
}
const api = createApiClient(fetch);
const result = await api.POST('/api/persons', {
body: { firstName, lastName, ...(alias ? { alias } : {}) }
});
if (!result.response.ok) {
return fail(result.response.status, { error: 'Person konnte nicht gespeichert werden.' });
}
throw redirect(303, `/persons/${result.data!.id}`);
}
};

View File

@@ -0,0 +1,100 @@
<script lang="ts">
let { form } = $props();
</script>
<div class="mx-auto max-w-2xl px-4 py-8">
<!-- Heading -->
<div class="mb-6">
<a
href="/persons"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Zurück zur Übersicht
</a>
<h1 class="font-serif text-3xl text-brand-navy">Neue Person</h1>
</div>
{#if form?.error}
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
{/if}
<form method="POST">
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
Angaben zur Person
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<div>
<label for="firstName" class="mb-1 block text-sm font-medium text-gray-700"
>Vorname *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<div>
<label for="lastName" class="mb-1 block text-sm font-medium text-gray-700"
>Nachname *</label
>
<input
id="lastName"
name="lastName"
type="text"
required
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<div class="md:col-span-2">
<label for="alias" class="mb-1 block text-sm font-medium text-gray-700"
>Rufname / Alias</label
>
<input
id="alias"
name="alias"
type="text"
placeholder="z.B. Oma Frieda, Onkel Karl…"
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
</div>
</div>
<!-- Save Bar -->
<div
class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm"
>
<a
href="/persons"
class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
>
Abbrechen
</a>
<button
type="submit"
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
>
Speichern
</button>
</div>
</form>
</div>

View File

@@ -36,7 +36,9 @@ export default defineConfig({
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }]
instances: [{ browser: 'chromium', headless: true }],
screenshotDirectory: 'test-results/screenshots',
screenshotFailures: true
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']

File diff suppressed because it is too large Load Diff