Compare commits
166 Commits
b583c8489d
...
feature/56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63013cc86a | ||
|
|
9e2419a48e | ||
|
|
00195dc8db | ||
|
|
0ec86220d3 | ||
|
|
7fbc33b32d | ||
|
|
93f57477cd | ||
|
|
34c66f80fc | ||
|
|
fd03e56c85 | ||
|
|
af57b4e530 | ||
|
|
aaa9286612 | ||
|
|
646674b06a | ||
|
|
1070e6e9ec | ||
|
|
3e5d296b09 | ||
|
|
ee49bac2ef | ||
|
|
48040dc7e4 | ||
|
|
83e5a1fde5 | ||
|
|
37f5c3d005 | ||
|
|
eb8bcdb426 | ||
|
|
05f3ce687f | ||
|
|
06e846f2f8 | ||
|
|
ea1c097ae0 | ||
|
|
b45ec744b2 | ||
|
|
ca5726e7c3 | ||
|
|
0ef81e20f6 | ||
|
|
1ad8fffd1b | ||
|
|
5fb6a1eec0 | ||
|
|
4f69457a68 | ||
|
|
62f62a89a1 | ||
|
|
d84b997965 | ||
|
|
8c86beb9f9 | ||
|
|
0020d1e773 | ||
|
|
47b8cc9340 | ||
|
|
3e65b2feb3 | ||
|
|
f32ed32f67 | ||
|
|
4a0d3b3bea | ||
|
|
d4b1a709d7 | ||
|
|
7af49daf9c | ||
|
|
28256dbd08 | ||
|
|
315b368f88 | ||
|
|
43defa41c4 | ||
|
|
17db73d900 | ||
|
|
88e3fb32b3 | ||
|
|
c18cdbfac1 | ||
|
|
b9aff799fa | ||
|
|
908221f04d | ||
|
|
5f49a5787c | ||
|
|
6400cef390 | ||
|
|
f98792f10b | ||
|
|
70d858b65a | ||
|
|
c1e82a7edf | ||
|
|
7fbfeb3b39 | ||
|
|
bbac351f03 | ||
|
|
2411c330a2 | ||
|
|
7d095e159e | ||
|
|
ca73777010 | ||
|
|
0221382c8a | ||
|
|
ea6b727e44 | ||
|
|
2a46136f61 | ||
|
|
c0b9d979ea | ||
|
|
c84bb3ca7b | ||
|
|
cf8425d744 | ||
|
|
1fcd8a6ad6 | ||
|
|
fb4f8e820c | ||
|
|
9731afb776 | ||
|
|
f6634f1d00 | ||
|
|
18601db4f8 | ||
|
|
a65c69b0ce | ||
|
|
8f5c13f162 | ||
|
|
168225d67c | ||
|
|
401a1f359f | ||
|
|
82c8401167 | ||
|
|
2f803b2740 | ||
|
|
da0d5495d0 | ||
|
|
513a7290b0 | ||
|
|
0f8b582813 | ||
|
|
4026bb9003 | ||
|
|
f2f9a1bf03 | ||
|
|
76031de8eb | ||
|
|
e2874528cd | ||
|
|
aa127de9bd | ||
|
|
65a8048e25 | ||
|
|
1ab063486c | ||
|
|
0a1075e03f | ||
|
|
ca212e871f | ||
|
|
0525e66d55 | ||
|
|
acf6fc05ad | ||
|
|
f950e4e826 | ||
|
|
db2fc33e99 | ||
|
|
28dea45cc3 | ||
|
|
11f6f9e2a2 | ||
|
|
4771832492 | ||
|
|
c006113db9 | ||
|
|
5160009175 | ||
|
|
4009781064 | ||
|
|
761c903111 | ||
|
|
931a8dac95 | ||
|
|
3f717e3266 | ||
|
|
203b7d2b08 | ||
|
|
e9b03ee6a9 | ||
|
|
ba04e62f87 | ||
|
|
fa4bfb8e5c | ||
|
|
fde75f3fcf | ||
|
|
03a1a86cdb | ||
|
|
55ffaa1c5c | ||
|
|
1fdde95b09 | ||
|
|
c056d804e6 | ||
|
|
490382b5de | ||
|
|
557b62ac5c | ||
|
|
4ccc8d69d0 | ||
|
|
c3f487f16c | ||
|
|
6e6663376d | ||
|
|
041bbdc2e6 | ||
|
|
08f7ae9a5c | ||
|
|
c01a07bd82 | ||
|
|
cccc12429c | ||
|
|
b07391541b | ||
|
|
fb08eb30a4 | ||
|
|
371d92f52a | ||
|
|
fe9b4a9569 | ||
|
|
7988c62246 | ||
|
|
a998ef4550 | ||
|
|
c4be2eb46e | ||
|
|
20313de4e9 | ||
|
|
4142c7cd83 | ||
|
|
0e76be5672 | ||
|
|
db6dc28528 | ||
|
|
208dc87d69 | ||
|
|
d943372ea7 | ||
|
|
4e8de3658f | ||
|
|
b6361e6cbc | ||
|
|
a3948b6a0f | ||
|
|
0918e75803 | ||
|
|
56cbd290e3 | ||
|
|
9f3f022ec0 | ||
|
|
6ef7b292cc | ||
|
|
a65cbf9bae | ||
|
|
a60905674f | ||
|
|
e6db43850b | ||
|
|
802f1ab0e0 | ||
|
|
9b67db74eb | ||
|
|
3280125140 | ||
|
|
553fa8a4b9 | ||
|
|
409e70078c | ||
|
|
5f36930b6b | ||
|
|
b456c8f1bd | ||
|
|
3f987ca48f | ||
|
|
225d6e44c9 | ||
|
|
ded5c24c40 | ||
|
|
f4e6fe587c | ||
|
|
0d0aa83c0c | ||
|
|
dcb26e201a | ||
|
|
fcaf6dc322 | ||
|
|
f59f4d304a | ||
|
|
f2030ca4ee | ||
|
|
49fd29c1e6 | ||
|
|
3b04f4cafe | ||
|
|
ed32e1728d | ||
|
|
273b43261f | ||
|
|
7cb20dec50 | ||
|
|
973620a097 | ||
|
|
b3de5f885d | ||
|
|
4417fc9828 | ||
|
|
25e095ea47 | ||
|
|
97e5255d7f | ||
|
|
6b5c78f789 | ||
|
|
0123dffdc4 |
200
.gitea/workflows/ci.yml
Normal file
200
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,200 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
# ─── Unit & Browser Component Tests ──────────────────────────────────────────
|
||||
# Runs inside the official Playwright Docker image — Chromium and all system
|
||||
# deps are pre-installed, so no install or cache step is needed for the browser.
|
||||
unit-tests:
|
||||
name: Unit & Component Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run unit and component tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: unit-test-screenshots
|
||||
path: frontend/test-results/screenshots/
|
||||
|
||||
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
|
||||
# Pure Mockito + WebMvcTest — no DB or S3 needed.
|
||||
backend-unit-tests:
|
||||
name: Backend Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Maven repository
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: maven-${{ hashFiles('backend/pom.xml') }}
|
||||
restore-keys: maven-
|
||||
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
chmod +x mvnw
|
||||
./mvnw clean test
|
||||
working-directory: backend
|
||||
|
||||
# ─── E2E Tests ────────────────────────────────────────────────────────────────
|
||||
# Needs: PostgreSQL + MinIO (via docker-compose) + Spring Boot + SvelteKit dev server.
|
||||
# Test data is seeded by DataInitializer on first startup (admin user + e2e profile data).
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# These env vars are picked up by docker-compose (overrides .env file)
|
||||
env:
|
||||
DOCKER_API_VERSION: "1.43"
|
||||
POSTGRES_USER: archive_user
|
||||
POSTGRES_PASSWORD: ci_db_password
|
||||
POSTGRES_DB: family_archive_db
|
||||
MINIO_ROOT_USER: minio_admin
|
||||
MINIO_ROOT_PASSWORD: ci_minio_password
|
||||
MINIO_DEFAULT_BUCKETS: archive-documents
|
||||
PORT_DB: 5433
|
||||
PORT_MINIO_API: 9100
|
||||
PORT_MINIO_CONSOLE: 9101
|
||||
PORT_BACKEND: 8080
|
||||
PORT_FRONTEND: 3000
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ── Infrastructure ──────────────────────────────────────────────────────
|
||||
- name: Cleanup leftover containers from previous runs
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down --volumes --remove-orphans || true
|
||||
|
||||
- name: Start DB and MinIO
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d db minio create-buckets
|
||||
|
||||
- name: Wait for DB to be ready
|
||||
run: |
|
||||
timeout 30 bash -c \
|
||||
'until docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T db pg_isready -U archive_user; do sleep 2; done'
|
||||
|
||||
- name: Connect job container to compose network
|
||||
run: docker network connect familienarchiv_archive-net $(cat /etc/hostname)
|
||||
|
||||
# ── Backend ─────────────────────────────────────────────────────────────
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Maven repository
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: maven-${{ hashFiles('backend/pom.xml') }}
|
||||
restore-keys: maven-
|
||||
|
||||
- name: Build backend (skip tests — covered by separate Java test job)
|
||||
run: |
|
||||
chmod +x mvnw
|
||||
./mvnw clean package -DskipTests
|
||||
working-directory: backend
|
||||
|
||||
- name: Start backend
|
||||
run: |
|
||||
java -jar backend/target/*.jar \
|
||||
--spring.profiles.active=e2e \
|
||||
--SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/family_archive_db \
|
||||
--SPRING_DATASOURCE_USERNAME=archive_user \
|
||||
--SPRING_DATASOURCE_PASSWORD=ci_db_password \
|
||||
--S3_ENDPOINT=http://minio:9000 \
|
||||
--S3_ACCESS_KEY=minio_admin \
|
||||
--S3_SECRET_KEY=ci_minio_password \
|
||||
--S3_BUCKET_NAME=archive-documents \
|
||||
--S3_REGION=us-east-1 \
|
||||
--APP_ADMIN_USERNAME=admin \
|
||||
--APP_ADMIN_PASSWORD=admin123 \
|
||||
&
|
||||
echo "Waiting for backend..."
|
||||
timeout 90 bash -c \
|
||||
'until curl -sf http://localhost:8080/actuator/health | grep -q "UP"; do sleep 3; done'
|
||||
echo "Backend is up."
|
||||
|
||||
# ── Frontend ─────────────────────────────────────────────────────────────
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ hashFiles('frontend/package-lock.json') }}
|
||||
|
||||
- name: Install Playwright Chromium + system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install Playwright system deps (browser binary already cached)
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
working-directory: frontend
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
working-directory: frontend
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
E2E_USERNAME: admin
|
||||
E2E_PASSWORD: admin123
|
||||
E2E_BACKEND_URL: http://localhost:8080
|
||||
|
||||
- name: Upload E2E results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: e2e-results
|
||||
path: frontend/test-results/e2e/
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,9 +1,14 @@
|
||||
# Runtime data (Docker volumes)
|
||||
data/
|
||||
import-data/
|
||||
import/
|
||||
gitea/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Dev scripts / DB dumps
|
||||
scripts/large-data.sql
|
||||
|
||||
.vitest-attachments
|
||||
**/test-results/
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
cd frontend && npm run lint
|
||||
321
CLAUDE.md
321
CLAUDE.md
@@ -4,23 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Familienarchiv** is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel batch import, full-text search, conversation threads between family members, and role-based access control.
|
||||
**Familienarchiv** is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel/ODS batch import, full-text search, conversation threads between family members, and role-based access control.
|
||||
|
||||
## Collaboration Principles
|
||||
## Collaboration
|
||||
|
||||
**Be honest and objective**: Evaluate all suggestions, ideas, and feedback on their technical merits. Don't be overly complimentary or sycophantic. If something doesn't make sense, doesn't align with best practices, or could be improved, say so directly and constructively. Technical accuracy and project quality take precedence over being agreeable.
|
||||
See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, and the Research → Plan → Implement → Validate cycle.
|
||||
|
||||
## Core Workflow: Research → Plan → Implement → Validate
|
||||
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
|
||||
|
||||
**Start every feature with:** "Let me research the codebase and create a plan before implementing."
|
||||
|
||||
1. **Research** - Understand existing patterns and architecture
|
||||
2. **Plan** - Propose approach and verify with you
|
||||
3. **Implement** - Build with tests and error handling
|
||||
4. **Validate** - ALWAYS run formatters, linters, and tests after implementation
|
||||
|
||||
- Whenever working on a feature or issue, let's always come up with a plan first, then save it to a file called `/.agent/current-plan.md`, before getting started with code changes. Update this file as the work progresses.
|
||||
- Let's use pure functions where possible to improve readability and testing.
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -62,48 +54,299 @@ npm run lint # Prettier + ESLint check
|
||||
npm run format # Auto-fix formatting
|
||||
npm run check # svelte-check (type checking)
|
||||
npm run test # Vitest unit tests
|
||||
npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
||||
# (requires backend running with --spring.profiles.active=dev)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
---
|
||||
|
||||
### Backend (`backend/src/main/java/org/raddatz/familienarchiv/`)
|
||||
## Backend Architecture
|
||||
|
||||
Layered architecture:
|
||||
### Package Structure
|
||||
|
||||
- **`model/`** — JPA entities: `Document`, `Person`, `AppUser`, `UserGroup`, `Tag`, `DocumentStatus` (enum: PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED)
|
||||
- **`repository/`** — Spring Data repositories + `DocumentSpecifications` for complex filtered queries
|
||||
- **`service/`** — Core business logic: `DocumentService` (uploads, search, Excel import), `FileService` (MinIO/S3), `ExcelService`, `MassImportService`, `UserService`
|
||||
- **`controller/`** — REST endpoints: `DocumentController`, `PersonController`, `UserController`, `AdminController`, `GroupController`, `TagController`
|
||||
- **`security/`** — `SecurityConfig` (HTTP Basic + form login, CSRF disabled), `PermissionAspect` (AOP enforcement of `@RequirePermission`), `CustomUserDetailsService`
|
||||
- **`config/`** — `MinioConfig` (creates S3Client, validates connectivity on startup), `AsyncConfig`
|
||||
```
|
||||
backend/src/main/java/org/raddatz/familienarchiv/
|
||||
├── controller/ REST endpoints — thin, delegate everything to services
|
||||
├── service/ Business logic — the only place that touches repositories
|
||||
├── repository/ Spring Data JPA interfaces
|
||||
├── model/ JPA entities
|
||||
├── dto/ Input objects (request bodies/form data)
|
||||
├── exception/ DomainException + ErrorCode enum
|
||||
├── security/ SecurityConfig, Permission enum, @RequirePermission, PermissionAspect
|
||||
└── config/ MinioConfig, AsyncConfig
|
||||
```
|
||||
|
||||
Database migrations live in `src/main/resources/db/migration/` (Flyway). Configuration in `src/main/resources/application.properties` — most values injected from environment variables (DB credentials, MinIO endpoint/credentials/bucket, upload limits, Excel column mappings).
|
||||
### Layering Rules (strictly enforced)
|
||||
|
||||
### Frontend (`backend/workspaces/frontend/src/`)
|
||||
```
|
||||
Controller → Service → Repository → DB
|
||||
```
|
||||
|
||||
- **`hooks.server.ts`** — Central middleware: reads `auth_token` cookie, injects it into all API calls to the backend, loads current user context
|
||||
- **`routes/`** — File-based routing. Main pages: `/` (search/home), `/documents/[id]`, `/documents/[id]/edit`, `/persons`, `/persons/[id]`, `/conversations`, `/admin`, `/login`
|
||||
- **`routes/api/`** — SvelteKit API endpoints for typeahead (persons, tags) — these call the Spring Boot backend
|
||||
- **`lib/components/`** — `PersonTypeahead.svelte`, `TagInput.svelte`
|
||||
- **`messages/`** — Paraglide.js translation files (`de.json`, `en.json`, `es.json`)
|
||||
- **Controllers** never inject or call repositories directly.
|
||||
- **Services** never reach into another domain's repository. Call the other domain's service instead.
|
||||
- ✅ `DocumentService` → `PersonService.getById()` → `PersonRepository`
|
||||
- ❌ `DocumentService` → `PersonRepository` directly
|
||||
- This keeps domain boundaries clear and business logic testable in isolation.
|
||||
|
||||
Authentication: form login → backend sets session → `auth_token` cookie → hooks.server.ts injects into all backend requests.
|
||||
### Domain Model
|
||||
|
||||
### Key Design Patterns
|
||||
| Entity | Table | Key relationships |
|
||||
|---|---|---|
|
||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
|
||||
- **Search**: `DocumentSpecifications` (Spring Data JPA Specification pattern) enables composable, dynamic query building for the document search endpoint
|
||||
- **Permissions**: `@RequirePermission` annotation processed by `PermissionAspect` (AOP) — checks user's `UserGroup` permissions at the method level
|
||||
- **Excel Import**: Configurable column index mapping in `application.properties`; `ExcelService` parses → `MassImportService` upserts documents
|
||||
- **File Storage**: `FileService` wraps AWS SDK v2 `S3Client` with path-style access for MinIO compatibility
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
### Infrastructure
|
||||
- `PLACEHOLDER`: created during Excel import, no file yet
|
||||
- `UPLOADED`: file has been stored in S3
|
||||
|
||||
The `docker-compose.yml` at the repo root orchestrates everything. A MinIO MC helper container runs at startup to create the `archive-documents` bucket and set permissions. The backend container depends on both `db` and `minio` being healthy before starting.
|
||||
### Entity Code Style
|
||||
|
||||
All entities use these Lombok annotations:
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "table_name")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class MyEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) // marks field as required in OpenAPI spec
|
||||
private UUID id;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- `@Schema(requiredMode = REQUIRED)` must be added to every field the backend always populates (id, non-null fields). This drives the TypeScript type generation.
|
||||
- Collections use `@Builder.Default` with `new HashSet<>()` as the default.
|
||||
- Timestamps use `@CreationTimestamp` / `@UpdateTimestamp`.
|
||||
|
||||
### Services
|
||||
|
||||
Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optionally `@Slf4j`.
|
||||
|
||||
- Write methods are annotated `@Transactional`.
|
||||
- Read methods are not annotated (default non-transactional is fine).
|
||||
- Each service owns its domain's repository. Cross-domain data access goes through the other domain's service.
|
||||
|
||||
**Existing services:**
|
||||
|
||||
| Service | Responsibility |
|
||||
|---|---|
|
||||
| `DocumentService` | Document CRUD, search, tag cascade delete |
|
||||
| `PersonService` | Person CRUD, find-or-create by alias |
|
||||
| `TagService` | Tag find/create/update/delete |
|
||||
| `UserService` | User and group CRUD |
|
||||
| `FileService` | S3/MinIO upload and download |
|
||||
| `MassImportService` | Async ODS/Excel import; delegates to PersonService and TagService |
|
||||
| `ExcelService` | Lower-level spreadsheet parsing |
|
||||
|
||||
### DTOs
|
||||
|
||||
Input DTOs live in `dto/`. Response types are the model entities themselves (no response DTOs).
|
||||
|
||||
- `DocumentUpdateDTO` — used for both create and update (all fields optional)
|
||||
- `CreateUserRequest` — user creation
|
||||
- `GroupDTO` — group create/update
|
||||
|
||||
### Error Handling
|
||||
|
||||
Use `DomainException` for all domain errors. Never throw raw exceptions from service methods.
|
||||
|
||||
```java
|
||||
// Static factories match common HTTP status codes:
|
||||
DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)
|
||||
DomainException.forbidden("Access denied")
|
||||
DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "Already running")
|
||||
DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Upload failed: " + e.getMessage())
|
||||
```
|
||||
|
||||
`ErrorCode` is an enum in `exception/ErrorCode.java`. When adding a new error case, add the value there **and** mirror it in the frontend's `src/lib/errors.ts` + add a Paraglide translation key.
|
||||
|
||||
For simple validation in controllers (not domain logic), `ResponseStatusException` is acceptable:
|
||||
```java
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "firstName is required");
|
||||
```
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
Use `@RequirePermission` on controller methods (or the whole controller class):
|
||||
|
||||
```java
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Document updateDocument(...) { ... }
|
||||
```
|
||||
|
||||
Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`
|
||||
|
||||
`PermissionAspect` (AOP) checks the current user's `UserGroup.permissions` at runtime.
|
||||
|
||||
### OpenAPI / API Types
|
||||
|
||||
SpringDoc generates the spec at `/v3/api-docs` (only accessible when running with `--spring.profiles.active=dev`).
|
||||
|
||||
When changing any model field or endpoint:
|
||||
1. Rebuild the backend JAR with `-DskipTests`
|
||||
2. Start it with `--spring.profiles.active=dev`
|
||||
3. Run `npm run generate:api` in `frontend/`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Route Structure
|
||||
|
||||
```
|
||||
frontend/src/routes/
|
||||
├── +layout.svelte Global header (sticky), nav links, logout
|
||||
├── +layout.server.ts Loads current user, injects auth cookie
|
||||
├── +page.svelte Home / document search
|
||||
├── +page.server.ts Load: search documents; no actions
|
||||
├── documents/
|
||||
│ ├── [id]/+page.svelte Document detail (view + file preview)
|
||||
│ └── [id]/edit/ Edit form (all metadata + file upload)
|
||||
│ └── new/ Create form (same fields, empty)
|
||||
├── persons/
|
||||
│ ├── +page.svelte Person list with search
|
||||
│ ├── [id]/+page.svelte Person detail (inline edit + merge)
|
||||
│ └── new/ Create person form
|
||||
├── conversations/ Bilateral conversation timeline
|
||||
├── admin/ User + group + tag management
|
||||
└── login/ logout/ Auth pages
|
||||
```
|
||||
|
||||
### API Client Pattern
|
||||
|
||||
All server-side API calls use the typed client from `$lib/api.server.ts`:
|
||||
|
||||
```typescript
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
|
||||
|
||||
// Always check via response.ok, NOT result.error
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
return { person: result.data! };
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- Use `!result.response.ok` for error checking (not `if (result.error)` — this breaks when the spec has no error responses defined)
|
||||
- Cast errors as `result.error as unknown as { code?: string }` to extract the backend error code
|
||||
- Use `result.data!` (non-null assertion) after an ok check — TypeScript knows it's present
|
||||
|
||||
For multipart/form-data endpoints (file uploads), bypass the typed client and use raw `fetch`:
|
||||
```typescript
|
||||
const res = await fetch(`${baseUrl}/api/documents`, { method: 'POST', body: formData });
|
||||
```
|
||||
|
||||
### Form Actions Pattern
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string; // cast needed — FormData returns FormDataEntryValue
|
||||
// ...
|
||||
return fail(400, { error: 'message' }); // on error
|
||||
throw redirect(303, '/target'); // on success
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Date Handling
|
||||
|
||||
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO format to the backend.
|
||||
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC timezone off-by-one:
|
||||
```typescript
|
||||
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
.format(new Date(doc.documentDate + 'T12:00:00'))
|
||||
```
|
||||
|
||||
### UI Component Library
|
||||
|
||||
Custom components in `src/lib/components/`:
|
||||
|
||||
| Component | Props | Description |
|
||||
|---|---|---|
|
||||
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead dropdown |
|
||||
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
|
||||
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
|
||||
|
||||
### Styling Conventions (Tailwind CSS 4)
|
||||
|
||||
Brand color utilities (defined in `layout.css`):
|
||||
|
||||
| Class | Value | Usage |
|
||||
|---|---|---|
|
||||
| `brand-navy` | `#002850` | Primary text, buttons, headers |
|
||||
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
|
||||
| `brand-sand` | `#E4E2D7` | Page background, card borders |
|
||||
|
||||
Typography:
|
||||
- `font-serif` (Merriweather) — body text, document titles, names
|
||||
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
||||
|
||||
Card pattern for content sections:
|
||||
```svelte
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section Title</h2>
|
||||
<!-- content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
Save bar pattern — use **sticky full-bleed** for long forms (edit document), **card-style with `mt-4`** for short forms (new person):
|
||||
```svelte
|
||||
<!-- Long forms: sticky, full-bleed -->
|
||||
<div class="sticky bottom-0 z-10 -mx-4 px-6 py-4 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] flex items-center justify-between">
|
||||
|
||||
<!-- Short forms: card, top margin -->
|
||||
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
|
||||
```
|
||||
|
||||
Back link pattern:
|
||||
```svelte
|
||||
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4">
|
||||
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" .../>
|
||||
Zurück zur Übersicht
|
||||
</a>
|
||||
```
|
||||
|
||||
Subtle action link (e.g. "new document/person"):
|
||||
```svelte
|
||||
<a href="/documents/new" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors">
|
||||
<svg class="w-4 h-4" ...><!-- plus icon --></svg>
|
||||
Neues Dokument
|
||||
</a>
|
||||
```
|
||||
|
||||
### Error Handling (Frontend)
|
||||
|
||||
`src/lib/errors.ts` mirrors the backend `ErrorCode` enum and maps codes to Paraglide translation keys. When adding a new `ErrorCode` on the backend:
|
||||
1. Add it to `ErrorCode.java`
|
||||
2. Add it to the `ErrorCode` type in `errors.ts`
|
||||
3. Add a `case` in `getErrorMessage()`
|
||||
4. Add the translation key in `messages/de.json`, `en.json`, `es.json`
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
The `docker-compose.yml` at the repo root orchestrates everything. A MinIO MC helper container runs at startup to create the `archive-documents` bucket. The backend container depends on both `db` and `minio` being healthy.
|
||||
|
||||
Database migrations live in `backend/src/main/resources/db/migration/` (Flyway, SQL files named `V{n}__{description}.sql`).
|
||||
|
||||
## API Testing
|
||||
|
||||
HTTP test files are in `backend/api_tests/` (`Document.http`, `User.http`) for use with the VS Code REST Client extension.
|
||||
HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client extension.
|
||||
|
||||
## Dev Container
|
||||
|
||||
A `.devcontainer/` config is available (Java 21 + Node 24, with ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" to get a pre-configured environment with Spring Boot Tools, Lombok support, and database/MinIO services running.
|
||||
A `.devcontainer/` config is available (Java 21 + Node 24, ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" for a pre-configured environment.
|
||||
|
||||
329
CODESTYLE.md
Normal file
329
CODESTYLE.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Code Style Guide
|
||||
|
||||
This document defines the coding standards for the Familienarchiv project. It applies to both the Java backend and the TypeScript/Svelte frontend. When in doubt, prefer code that a competent developer can read and understand without explanation.
|
||||
|
||||
---
|
||||
|
||||
## Clean Code (Uncle Bob)
|
||||
|
||||
These are principles, not laws. Apply judgment.
|
||||
|
||||
### Names reveal intent
|
||||
|
||||
A name should tell you *why* something exists, what it does, and how it is used — without needing a comment to explain it.
|
||||
|
||||
```java
|
||||
// Bad
|
||||
int d; // elapsed time in days
|
||||
List<Document> list2;
|
||||
|
||||
// Good
|
||||
int elapsedDays;
|
||||
List<Document> receivedDocuments;
|
||||
```
|
||||
|
||||
- No abbreviations unless universally understood (`id`, `url`, `dto`).
|
||||
- Boolean variables and methods should read as yes/no questions: `isEnabled`, `hasFile`, `canWrite`.
|
||||
- Avoid redundant context: inside class `Document`, write `getTitle()` not `getDocumentTitle()`.
|
||||
|
||||
### Functions do one thing
|
||||
|
||||
A function that does one thing can rarely be meaningfully subdivided. If you can extract a chunk with a name that isn't just a restatement of what it does, it should probably be its own function.
|
||||
|
||||
```java
|
||||
// Bad — validates, transforms, and persists
|
||||
public Document saveDocument(DocumentUpdateDTO dto) {
|
||||
if (dto.getTitle() == null) throw new DomainException(...);
|
||||
String cleaned = dto.getTitle().strip();
|
||||
Document doc = documentRepository.findById(...).orElseThrow(...);
|
||||
doc.setTitle(cleaned);
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
// Good — one responsibility per method; caller orchestrates
|
||||
public Document updateDocument(UUID id, DocumentUpdateDTO dto) {
|
||||
validateTitle(dto.getTitle());
|
||||
Document doc = getById(id);
|
||||
applyUpdate(doc, dto);
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
```
|
||||
|
||||
### Small functions, minimal parameters
|
||||
|
||||
- Functions longer than ~20 lines are a signal to look for a natural split.
|
||||
- Aim for ≤ 3 parameters. More than 3 is a sign the function is doing too much, or parameters should be grouped into an object.
|
||||
- Never use boolean flag arguments — they announce the function does two things:
|
||||
|
||||
```java
|
||||
// Bad
|
||||
renderDocument(doc, true); // what does true mean?
|
||||
|
||||
// Good
|
||||
renderDocumentWithPreview(doc);
|
||||
renderDocumentWithoutPreview(doc);
|
||||
```
|
||||
|
||||
### Guard clauses over deep nesting
|
||||
|
||||
Return or throw early for preconditions. Keep the happy path at the lowest nesting level.
|
||||
|
||||
```java
|
||||
// Bad
|
||||
public Document getDocument(UUID id, AppUser user) {
|
||||
if (id != null) {
|
||||
Document doc = repository.findById(id).orElse(null);
|
||||
if (doc != null) {
|
||||
if (user.canRead(doc)) {
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Good
|
||||
public Document getDocument(UUID id, AppUser user) {
|
||||
if (id == null) throw DomainException.notFound(...);
|
||||
Document doc = repository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(...));
|
||||
if (!user.canRead(doc)) throw DomainException.forbidden(...);
|
||||
return doc;
|
||||
}
|
||||
```
|
||||
|
||||
### Comments: only for *why*, never for *what*
|
||||
|
||||
Code explains what it does. Comments explain why a non-obvious decision was made.
|
||||
|
||||
```typescript
|
||||
// Bad — restates the code
|
||||
// set auth cookie
|
||||
cookies.set('auth_token', authHeader, { path: '/' });
|
||||
|
||||
// Good — explains a non-obvious constraint
|
||||
// secure: false until the deployment is served over HTTPS
|
||||
cookies.set('auth_token', authHeader, { path: '/', secure: false });
|
||||
```
|
||||
|
||||
If you feel compelled to write a comment that explains *what* the code does, rewrite the code until it doesn't need one.
|
||||
|
||||
### No dead code
|
||||
|
||||
Remove commented-out code, unused variables, unused imports, and unreachable branches. Version control is the history — dead code in the file is noise.
|
||||
|
||||
### Command-query separation
|
||||
|
||||
A function either *does something* (command) or *answers something* (query) — not both.
|
||||
|
||||
```typescript
|
||||
// Bad — modifies state and returns a value
|
||||
function addTagAndReturnCount(tag: string): number { ... }
|
||||
|
||||
// Good — separate concerns
|
||||
function addTag(tag: string): void { ... }
|
||||
function getTagCount(): number { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DRY vs KISS — KISS wins
|
||||
|
||||
**DRY** (Don't Repeat Yourself): every piece of knowledge has a single, authoritative representation.
|
||||
|
||||
**KISS** (Keep It Simple, Stupid): prefer the simplest solution that works.
|
||||
|
||||
**When they conflict, KISS wins.** Do not create an abstraction to eliminate duplication unless the abstraction has a clear, stable name and genuinely reduces cognitive load.
|
||||
|
||||
### Practical rules
|
||||
|
||||
**Extract when:**
|
||||
- The same logic appears in 3+ places *and* it has a meaningful name that isn't just a description of the lines it replaces.
|
||||
- The extracted unit is independently testable.
|
||||
- The abstraction makes the call site *more* readable, not less.
|
||||
|
||||
**Don't extract when:**
|
||||
- Two things look similar but might diverge independently — coupling them through an abstraction would make future changes harder.
|
||||
- The extracted function would be used exactly once.
|
||||
- Naming the abstraction requires a long or awkward name.
|
||||
|
||||
```typescript
|
||||
// Three similar lines — do NOT abstract prematurely
|
||||
const sentYearRange = yearRange(sentDocuments);
|
||||
const receivedYearRange = yearRange(receivedDocuments);
|
||||
|
||||
// yearRange() is worth extracting because it has a clear name,
|
||||
// is used in multiple places, and is independently testable.
|
||||
// But if it were only used once, keep it inline.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SOLID Principles
|
||||
|
||||
Applied to this stack.
|
||||
|
||||
### S — Single Responsibility
|
||||
|
||||
Each class, service, or component has one reason to change. In practice:
|
||||
|
||||
- **Backend:** Controllers receive HTTP, delegate everything to services. Services contain business logic, never touch another domain's repository directly.
|
||||
- **Frontend:** Components render UI. Server files (`+page.server.ts`) load and validate data. Don't put business logic in Svelte components.
|
||||
- **Wrong:** A `DocumentService` that also manages user sessions.
|
||||
- **Right:** `DocumentService` owns documents; `UserService` owns users; each is ignorant of the other's internal details.
|
||||
|
||||
### O — Open/Closed
|
||||
|
||||
Code should be open for extension and closed for modification. Prefer adding new code over editing existing code to support new behavior.
|
||||
|
||||
```java
|
||||
// Bad — adding a new export format requires editing this method
|
||||
public byte[] export(String format) {
|
||||
if (format.equals("csv")) { ... }
|
||||
else if (format.equals("pdf")) { ... } // added later, modifies existing method
|
||||
}
|
||||
|
||||
// Good — each format is a separate implementation
|
||||
public interface DocumentExporter {
|
||||
byte[] export(List<Document> documents);
|
||||
}
|
||||
public class CsvExporter implements DocumentExporter { ... }
|
||||
public class PdfExporter implements DocumentExporter { ... }
|
||||
```
|
||||
|
||||
In practice: when adding a variant of existing behavior, reach for a new class/function before editing an existing one.
|
||||
|
||||
### L — Liskov Substitution
|
||||
|
||||
Subtypes must be usable wherever the parent type is expected, without breaking behavior. Concretely:
|
||||
|
||||
- If you extend a service or implement an interface, the subtype must honor the contracts (error cases, return semantics) of the parent.
|
||||
- Don't override a method to make it a no-op or throw unconditionally — that breaks callers who rely on the contract.
|
||||
|
||||
### I — Interface Segregation
|
||||
|
||||
Don't force callers to depend on methods they don't use. Keep interfaces and services focused.
|
||||
|
||||
```java
|
||||
// Bad — DocumentService exposed to ImportService even though import only needs findOrCreate
|
||||
public class MassImportService {
|
||||
private final DocumentService documentService; // 40+ methods, only 2 needed
|
||||
}
|
||||
|
||||
// Good — expose only what's needed via a targeted service method or a narrow interface
|
||||
public class MassImportService {
|
||||
private final PersonService personService; // only needs findOrCreateByName
|
||||
private final TagService tagService; // only needs findOrCreate
|
||||
}
|
||||
```
|
||||
|
||||
### D — Dependency Inversion
|
||||
|
||||
High-level modules should not depend on low-level modules. Both should depend on abstractions.
|
||||
|
||||
- **Backend:** Spring's `@Autowired` / constructor injection handles this. Always inject interfaces or Spring beans, never instantiate services with `new` inside a controller or service.
|
||||
- **Frontend:** Pass data into components via props rather than fetching it inside the component. Components should receive data; server files should supply it.
|
||||
|
||||
```typescript
|
||||
// Bad — component fetches its own data (depends on network/fetch implementation)
|
||||
onMount(async () => {
|
||||
persons = await fetch('/api/persons').then(r => r.json());
|
||||
});
|
||||
|
||||
// Good — data flows in via props from the server load function
|
||||
let { data } = $props(); // data.persons supplied by +page.server.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Formatting and Style Specifics
|
||||
|
||||
These complement the principles above with project-specific conventions.
|
||||
|
||||
### Both Java and TypeScript
|
||||
|
||||
- One concept per line — don't chain side-effects.
|
||||
- No magic numbers — extract named constants.
|
||||
- Fail fast: validate inputs at the boundary (controller / server load), trust internal code.
|
||||
|
||||
### Java (backend)
|
||||
|
||||
- Use `DomainException` static factories for all domain errors — never throw raw `RuntimeException`.
|
||||
- `@Transactional` only on write methods, not reads.
|
||||
- Entities use `@Builder` — construct with builder pattern, not setters, in tests.
|
||||
- Avoid `Optional.get()` without `orElseThrow` — always provide a meaningful exception.
|
||||
|
||||
### TypeScript / Svelte (frontend)
|
||||
|
||||
- `$derived` over `$effect` for computed values — effects are for side-effects only.
|
||||
- Check `!result.response.ok` for API errors, not `result.error` (see CLAUDE.md).
|
||||
- Prefer typed API client calls over raw `fetch` — use raw `fetch` only for multipart uploads.
|
||||
- Svelte component logic in `<script>`, layout/styles in template — no business logic in markup.
|
||||
|
||||
---
|
||||
|
||||
## Svelte 5 — Specific Rules
|
||||
|
||||
These rules are enforced by ESLint (`eslint-plugin-svelte`). Knowing *why* they exist prevents the need to fix violations after the fact.
|
||||
|
||||
### Always key `{#each}` blocks
|
||||
|
||||
Without a key, Svelte tracks list items by array position. When items are added, removed, or reordered, Svelte patches DOM nodes in-place from the top — it never moves the correct node. Component-local state (counters, animation state, focus) becomes permanently attached to the wrong item. This is a silent data integrity bug, not a crash.
|
||||
|
||||
```svelte
|
||||
<!-- Bad — position-based tracking; reordering silently corrupts local state -->
|
||||
{#each documents as doc}
|
||||
<DocumentCard {doc} />
|
||||
{/each}
|
||||
|
||||
<!-- Good — identity-based; each node follows its data through reorders -->
|
||||
{#each documents as doc (doc.id)}
|
||||
<DocumentCard {doc} />
|
||||
{/each}
|
||||
```
|
||||
|
||||
Use `(item.id)` when items have a stable ID. Use the loop index `(i)` only for static lists that will never be reordered. Use `(item)` for primitive lists.
|
||||
|
||||
### Use `$derived` for computed values, never `$state` + `$effect`
|
||||
|
||||
`$effect` is for *side effects* (DOM calls, network, logging). Using it to assign a computed value introduces a timing problem: `$derived` updates synchronously before the render, while `$effect` runs *after* the render — meaning the component briefly displays a stale value. It also triggers a second reactive pass, doubling the work.
|
||||
|
||||
```svelte
|
||||
<!-- Bad — stale value during render; extra reactive cycle; unclear intent -->
|
||||
<script>
|
||||
let fullName = $state('');
|
||||
$effect(() => {
|
||||
fullName = `${person.firstName} ${person.lastName}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Good — synchronous, single-pass, intent is obvious -->
|
||||
<script>
|
||||
const fullName = $derived(`${person.firstName} ${person.lastName}`);
|
||||
</script>
|
||||
```
|
||||
|
||||
Use `$derived.by(() => { ... })` when the computation needs multiple statements.
|
||||
|
||||
### Use Svelte reactive collections, not plain JS ones
|
||||
|
||||
Svelte 5's reactivity tracks object *references*, not mutations. When you call `.set()` on a plain `Map` or `.set()` on a plain `URLSearchParams`, the reference doesn't change — Svelte never notices, and the UI goes silently stale.
|
||||
|
||||
`SvelteMap`, `SvelteSet`, and `SvelteURLSearchParams` from `svelte/reactivity` wrap the native classes and hook into Svelte's dependency tracker. Every mutation notifies the reactive graph; every read registers a dependency.
|
||||
|
||||
```svelte
|
||||
<!-- Bad — mutations are invisible to Svelte; derived values never update -->
|
||||
<script>
|
||||
const freq = new Map<string, number>();
|
||||
freq.set('key', 1); // Svelte does not see this
|
||||
</script>
|
||||
|
||||
<!-- Good — mutations are tracked; all dependents re-run correctly -->
|
||||
<script>
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
const freq = new SvelteMap<string, number>();
|
||||
freq.set('key', 1); // Svelte tracks this
|
||||
</script>
|
||||
```
|
||||
|
||||
The same applies to `URLSearchParams` in reactive contexts — use `SvelteURLSearchParams`.
|
||||
187
COLLABORATING.md
Normal file
187
COLLABORATING.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Collaboration Rules
|
||||
|
||||
How we work together on this project.
|
||||
|
||||
## Honesty and Objectivity
|
||||
|
||||
Evaluate all suggestions on their technical merits. No sycophancy — if something doesn't make sense, doesn't align with best practices, or could be improved, say so directly and constructively. Technical accuracy and project quality take precedence over being agreeable.
|
||||
|
||||
## Core Workflow: Research → Plan → Implement → Validate
|
||||
|
||||
Every non-trivial feature or bug fix follows this sequence:
|
||||
|
||||
1. **Research** — Read the relevant code. Understand existing patterns before touching anything.
|
||||
2. **Plan** — Write a plan to `/.agent/current-plan.md` and align with the user before writing code. Update the plan as work progresses.
|
||||
3. **Implement** — Use Red/Green TDD (see below).
|
||||
4. **Validate** — Run formatters, linters, and tests after every implementation step.
|
||||
|
||||
Never start writing code without having read the relevant files first.
|
||||
|
||||
## Red/Green TDD
|
||||
|
||||
All new behavior is driven by tests written **before** the implementation. The cycle is:
|
||||
|
||||
1. **Red** — Write a test that captures the requirement. Run it and confirm it fails. A test that passes before the implementation is written is not testing anything real.
|
||||
2. **Green** — Write the minimum production code needed to make the test pass. No more.
|
||||
3. **Refactor** — Clean up the implementation (names, structure, duplication) while keeping the test green.
|
||||
4. **Commit** — The test and implementation ship together in a single logical commit.
|
||||
|
||||
Repeat for each new behavior.
|
||||
|
||||
### What level of test to write
|
||||
|
||||
| Scenario | Test type |
|
||||
|---|---|
|
||||
| Business logic, calculations, service rules | Unit test (`DocumentServiceTest`, etc.) |
|
||||
| HTTP contract, request validation, error codes | Controller slice test (`@WebMvcTest`) |
|
||||
| Full user-facing behavior, navigation, forms | E2E Playwright spec |
|
||||
|
||||
### Rules
|
||||
|
||||
- Never write production code without a failing test that requires it.
|
||||
- Keep the Green step minimal — resist adding "obvious" extras that have no test yet.
|
||||
- The Refactor step must not change behavior — if a test breaks, the refactor introduced a bug.
|
||||
- If a bug is reported with no test, write the failing test first, then fix it.
|
||||
|
||||
## User Journeys & E2E Acceptance Criteria
|
||||
|
||||
Every `feature` issue must include two sections before any implementation begins:
|
||||
|
||||
### 1. User Journey
|
||||
|
||||
A plain-prose description of the steps a user takes to get value from the feature. Written from the user's perspective, not the implementation's:
|
||||
|
||||
> User opens a document, clicks "History", sees a chronological list of changes with editor name and timestamp. Clicking a row expands the old vs. new values.
|
||||
|
||||
This makes the scope concrete and prevents scope creep — anything not in the journey is out of scope for the issue.
|
||||
|
||||
### 2. E2E Scenarios
|
||||
|
||||
One or more acceptance criteria written as Playwright-ready scenarios. These become the outermost Red test in the TDD cycle — no feature is considered done until all its E2E scenarios pass:
|
||||
|
||||
```
|
||||
Scenario: View edit history of a document
|
||||
Given I am on a document detail page
|
||||
When I click the "History" tab
|
||||
Then I see at least one revision entry
|
||||
And each entry shows the editor's name and a timestamp
|
||||
```
|
||||
|
||||
Use this format consistently. It maps directly to `test.describe` / `test` blocks in the Playwright spec.
|
||||
|
||||
### Where this fits in the workflow
|
||||
|
||||
```
|
||||
Issue (Journey + Scenarios) → Red E2E test → Implementation → Green
|
||||
```
|
||||
|
||||
The scenarios in the issue are the contract. Write them before planning, treat them as failing tests from day one.
|
||||
|
||||
---
|
||||
|
||||
## Issue Tracking (Gitea)
|
||||
|
||||
All work is tracked in **Gitea** at `http://192.168.178.71:3005` (repo `marcel/familienarchiv`). Never use todo files or CLAUDE.md notes as a substitute.
|
||||
|
||||
Create an issue whenever work is identified that isn't being done in the current session.
|
||||
|
||||
### Issue title formats
|
||||
|
||||
**`feature` label** — user story format:
|
||||
```
|
||||
As a [role] I want [capability] so/because [reason]
|
||||
```
|
||||
Examples:
|
||||
- "As a user I want to search documents so I can find a specific document faster"
|
||||
- "As an admin I want to add a new user so I don't have to restart the server"
|
||||
|
||||
**`bug` label** — user-facing impact, not the technical cause:
|
||||
```
|
||||
[What breaks] when [trigger]
|
||||
```
|
||||
Examples:
|
||||
- "Document list shows blank page when no results found"
|
||||
- "Upload fails silently when file exceeds 50MB"
|
||||
|
||||
**`devops` label** — infrastructure, CI/CD, deployment, tooling:
|
||||
- "Fix CI checkout failing due to unresolvable hostname"
|
||||
- "Add E2E test seed data for runner"
|
||||
|
||||
### Priority labels
|
||||
|
||||
- `priority: high` — blocking or urgent
|
||||
- `priority: medium` — normal
|
||||
- `priority: low` — nice to have
|
||||
|
||||
### Other labels
|
||||
|
||||
- `needs-discussion` — decision needed before work starts
|
||||
- `wontfix` — acknowledged, not addressing
|
||||
|
||||
## Branching and Pull Requests
|
||||
|
||||
All changes go through a branch and a pull request — never commit directly to `main`.
|
||||
|
||||
### Branch naming
|
||||
|
||||
```
|
||||
<type>/<short-description>
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `feat/received-documents-person-page`
|
||||
- `fix/tag-filter-url-sync`
|
||||
- `devops/docker-compose-v2`
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Create a branch from `main` before writing any code.
|
||||
2. Commit work to that branch.
|
||||
3. Open a pull request on Gitea targeting `main` for review.
|
||||
4. Wait for approval before merging.
|
||||
|
||||
The PR description should reference the related issue (`Closes #n` or `Refs #n`).
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Every commit must reference the relevant Gitea issue.
|
||||
|
||||
- `Closes #12` — commit fully resolves the issue (Gitea auto-closes it)
|
||||
- `Refs #12` — commit is related but doesn't fully close the issue
|
||||
|
||||
Place the reference at the end of the commit body:
|
||||
|
||||
```
|
||||
feat: add person typeahead to document edit form
|
||||
|
||||
Closes #7
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
### Atomic commits
|
||||
|
||||
Each commit must do exactly one logical thing. Never bundle multiple unrelated changes into a single commit, even if they are small.
|
||||
|
||||
**Wrong** — three changes in one commit:
|
||||
```
|
||||
fix(e2e+i18n): add missing DE translation, fix test selectors, fix lang switching
|
||||
```
|
||||
|
||||
**Right** — three separate commits:
|
||||
```
|
||||
fix(i18n): add missing person_btn_conversations DE translation
|
||||
fix(e2e): exclude /persons/new from person link selector
|
||||
fix(e2e): clear locale cookie when switching back to base language
|
||||
```
|
||||
|
||||
When in doubt, commit more often rather than less.
|
||||
|
||||
## Code Style
|
||||
|
||||
See [CODESTYLE.md](./CODESTYLE.md) for the full guide: Clean Code (Uncle Bob), DRY/KISS trade-offs, and SOLID principles applied to this stack.
|
||||
|
||||
Quick reminders:
|
||||
- Pure functions over stateful helpers where possible
|
||||
- No premature abstractions — KISS beats DRY
|
||||
- No backwards-compatibility shims for code that has no callers
|
||||
- Validate at system boundaries only (user input, external APIs)
|
||||
3
backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal 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
|
||||
@@ -1,8 +1,9 @@
|
||||
# Wir nutzen Java 21 (LTS), da Spring Boot 3 das empfiehlt
|
||||
FROM mcr.microsoft.com/devcontainers/java:1-21-bullseye
|
||||
FROM eclipse-temurin:21-jdk
|
||||
|
||||
# Optional: Zusätzliche OS-Pakete installieren
|
||||
# RUN apt-get update && apt-get install -y <package-name>
|
||||
WORKDIR /app
|
||||
|
||||
# Port für Spring Boot
|
||||
EXPOSE 8080
|
||||
|
||||
# Source code and mvnw are mounted via docker-compose volume at runtime.
|
||||
# Maven dependencies are cached in a named volume (~/.m2).
|
||||
CMD ["./mvnw", "spring-boot:run"]
|
||||
|
||||
378
backend/mvnw.cmd
vendored
378
backend/mvnw.cmd
vendored
@@ -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"
|
||||
|
||||
@@ -119,6 +119,10 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
@@ -144,7 +148,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<spring.profiles.active>dev</spring.profiles.active>
|
||||
<spring.profiles.active>dev,e2e</spring.profiles.active>
|
||||
</properties>
|
||||
</profile>
|
||||
<profile>
|
||||
|
||||
@@ -6,10 +6,12 @@ import java.util.concurrent.ThreadPoolExecutor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class AsyncConfig {
|
||||
@Bean
|
||||
public Executor taskExecutor() {
|
||||
|
||||
@@ -4,13 +4,16 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||
import org.raddatz.familienarchiv.repository.UserGroupRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
@@ -20,15 +23,12 @@ import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@DependsOn("flyway")
|
||||
public class DataInitializer {
|
||||
|
||||
@Value("${app.admin.username:admin}")
|
||||
@@ -43,13 +43,13 @@ public class DataInitializer {
|
||||
@Bean
|
||||
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
|
||||
return args -> {
|
||||
if (userRepository.count() == 0) {
|
||||
log.info("Keine User gefunden. Erstelle Default-Admin...");
|
||||
if (userRepository.findByUsername(adminUsername).isEmpty()) {
|
||||
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminUsername);
|
||||
|
||||
// 1. Admin Gruppe erstellen
|
||||
UserGroup adminGroup = UserGroup.builder()
|
||||
.name("Administrators")
|
||||
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
||||
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
||||
.build();
|
||||
groupRepository.save(adminGroup);
|
||||
|
||||
@@ -67,111 +67,123 @@ 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,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
return args -> {
|
||||
// Nur ausführen, wenn DB leer ist
|
||||
// Always ensure the read-only test user exists, even when seed data was already loaded.
|
||||
if (userRepository.findByUsername("reader").isEmpty()) {
|
||||
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
|
||||
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
|
||||
groupRepository.save(UserGroup.builder()
|
||||
.name("Leser")
|
||||
.permissions(Set.of("READ_ALL"))
|
||||
.build()));
|
||||
userRepository.save(AppUser.builder()
|
||||
.username("reader")
|
||||
.password(passwordEncoder.encode("reader123"))
|
||||
.groups(Set.of(leserGroup))
|
||||
.build());
|
||||
log.info("E2E seed: 'reader'-Testbenutzer erstellt.");
|
||||
}
|
||||
|
||||
if (personRepo.count() > 0) {
|
||||
log.info("Datenbank enthält bereits Daten. Überspringe Initialisierung.");
|
||||
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Generiere Testdaten...");
|
||||
log.info("E2E seed: Erstelle deterministische Testdaten...");
|
||||
|
||||
// 1. Personen erstellen
|
||||
List<Person> persons = new ArrayList<>();
|
||||
String[] firstNames = { "Hans", "Helga", "Thomas", "Maria", "Otto", "Anna", "Paul", "Lisa" };
|
||||
String[] lastNames = { "Müller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer" };
|
||||
// ── Persons ──────────────────────────────────────────────────────
|
||||
Person hans = personRepo.save(Person.builder()
|
||||
.firstName("Hans").lastName("Müller").build());
|
||||
Person anna = personRepo.save(Person.builder()
|
||||
.firstName("Anna").lastName("Schmidt").build());
|
||||
Person otto = personRepo.save(Person.builder()
|
||||
.firstName("Otto").lastName("Fischer").build());
|
||||
Person maria = personRepo.save(Person.builder()
|
||||
.firstName("Maria").lastName("Weber").build());
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
String fn = firstNames[ThreadLocalRandom.current().nextInt(firstNames.length)];
|
||||
String ln = lastNames[ThreadLocalRandom.current().nextInt(lastNames.length)];
|
||||
// ── Tags ─────────────────────────────────────────────────────────
|
||||
Tag tagFamilie = tagRepo.save(Tag.builder().name("Familie").build());
|
||||
Tag tagKrieg = tagRepo.save(Tag.builder().name("Krieg").build());
|
||||
Tag tagUrlaub = tagRepo.save(Tag.builder().name("Urlaub").build());
|
||||
|
||||
persons.add(personRepo.save(Person.builder()
|
||||
.firstName(fn)
|
||||
.lastName(ln)
|
||||
.alias(i % 5 == 0 ? "Alias " + i : null)
|
||||
.build()));
|
||||
}
|
||||
// Speichern (falls nicht im Loop geschehen, aber save returns entity)
|
||||
// Hier nutzen wir die return values aus dem Loop, da save() die ID setzt.
|
||||
// ── Documents ────────────────────────────────────────────────────
|
||||
// 1. Fully transcribed letter — used by search + detail E2E tests
|
||||
docRepo.save(Document.builder()
|
||||
.title("Geburtsurkunde Hans Müller")
|
||||
.originalFilename("geburtsurkunde_hans.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.documentDate(LocalDate.of(1923, 4, 12))
|
||||
.location("Berlin")
|
||||
.sender(hans)
|
||||
.receivers(Set.of(anna))
|
||||
.tags(Set.of(tagFamilie))
|
||||
.transcription("Hiermit wird beurkundet, dass Hans Müller am 12. April 1923 in Berlin geboren wurde.")
|
||||
.build());
|
||||
|
||||
// 2. Dokumente erstellen
|
||||
List<Document> documents = new ArrayList<>();
|
||||
String[] cities = { "Berlin", "München", "Hamburg", "Köln" };
|
||||
// 2. Letter with multiple receivers and tags — tests multi-receiver display
|
||||
docRepo.save(Document.builder()
|
||||
.title("Brief aus dem Krieg")
|
||||
.originalFilename("brief_krieg_1944.pdf")
|
||||
.status(DocumentStatus.TRANSCRIBED)
|
||||
.documentDate(LocalDate.of(1944, 6, 6))
|
||||
.location("Normandie")
|
||||
.sender(otto)
|
||||
.receivers(Set.of(anna, maria))
|
||||
.tags(Set.of(tagKrieg, tagFamilie))
|
||||
.transcription("Liebe Anna, ich schreibe dir aus der Front. Es geht mir den Umständen entsprechend gut.")
|
||||
.build());
|
||||
|
||||
for (int i = 0; i < 500; i++) {
|
||||
Person sender = persons.get(ThreadLocalRandom.current().nextInt(persons.size()));
|
||||
// 3. Postcard — no transcription, tests PLACEHOLDER status
|
||||
docRepo.save(Document.builder()
|
||||
.title("Urlaubspostkarte Ostsee")
|
||||
.originalFilename("postkarte_1965.jpg")
|
||||
.status(DocumentStatus.PLACEHOLDER)
|
||||
.documentDate(LocalDate.of(1965, 8, 3))
|
||||
.location("Rügen")
|
||||
.sender(anna)
|
||||
.receivers(Set.of(hans))
|
||||
.tags(Set.of(tagUrlaub))
|
||||
.build());
|
||||
|
||||
// Zufällige Empfänger (0 bis 3)
|
||||
Set<Person> receivers = new HashSet<>();
|
||||
int numReceivers = ThreadLocalRandom.current().nextInt(4);
|
||||
for (int j = 0; j < numReceivers; j++) {
|
||||
receivers.add(persons.get(ThreadLocalRandom.current().nextInt(persons.size())));
|
||||
}
|
||||
// 4. Document with no sender — tests null-sender display ("Unbekannt")
|
||||
docRepo.save(Document.builder()
|
||||
.title("Unbekanntes Dokument")
|
||||
.originalFilename("unbekannt.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER)
|
||||
.documentDate(LocalDate.of(1950, 1, 1))
|
||||
.location("München")
|
||||
.receivers(Set.of(maria))
|
||||
.build());
|
||||
|
||||
Document doc = Document.builder()
|
||||
.title("Dokument " + i)
|
||||
.originalFilename("scan_" + i + ".pdf")
|
||||
.status(i%2 == 0? DocumentStatus.TRANSCRIBED: DocumentStatus.PLACEHOLDER)
|
||||
.documentDate(LocalDate.now().minusDays(ThreadLocalRandom.current().nextInt(365 * 50))) // Bis
|
||||
// zu 50
|
||||
// Jahre
|
||||
// alt
|
||||
.location(cities[ThreadLocalRandom.current().nextInt(cities.length)])
|
||||
.transcription(i%2 == 0? LOREM_IPSUM_LANG: null)
|
||||
.sender(sender)
|
||||
.receivers(receivers)
|
||||
.build();
|
||||
// 5. Document with minimal metadata — tests sparse display
|
||||
docRepo.save(Document.builder()
|
||||
.title("Scan ohne Titel")
|
||||
.originalFilename("scan_ohne_titel.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.documentDate(LocalDate.of(1978, 11, 20))
|
||||
.location("Hamburg")
|
||||
.sender(maria)
|
||||
.receivers(Set.of(otto))
|
||||
.build());
|
||||
|
||||
documents.add(doc);
|
||||
}
|
||||
|
||||
// Batch Save ist performanter
|
||||
docRepo.saveAll(documents);
|
||||
|
||||
log.info("Initialisierung abgeschlossen: 4 Personen und 500 Dokumente erstellt.");
|
||||
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.",
|
||||
personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.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. ";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.raddatz.familienarchiv.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FlywayConfig {
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
@Bean(name = "flyway")
|
||||
public Flyway flyway() {
|
||||
log.info("Running Flyway migrations...");
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.baselineOnMigrate(true)
|
||||
.baselineVersion("4")
|
||||
.load();
|
||||
var result = flyway.migrate();
|
||||
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
|
||||
return flyway;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ public class SecurityConfig {
|
||||
.csrf(csrf -> csrf.disable())
|
||||
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Health endpoint must be open so CI/Docker health checks work without credentials
|
||||
auth.requestMatchers("/actuator/health").permitAll();
|
||||
// Password reset endpoints are unauthenticated by nature
|
||||
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||
// E2E test helper (only active under "e2e" profile)
|
||||
auth.requestMatchers("/api/auth/reset-token-for-test").permitAll();
|
||||
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
|
||||
if (environment.matchesProfiles("dev")) {
|
||||
auth.requestMatchers(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.BackfillResult;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.MassImportService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -18,6 +21,8 @@ import lombok.RequiredArgsConstructor;
|
||||
public class AdminController {
|
||||
|
||||
private final MassImportService massImportService;
|
||||
private final DocumentService documentService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
|
||||
@PostMapping("/trigger-import")
|
||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
||||
@@ -29,4 +34,17 @@ public class AdminController {
|
||||
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
|
||||
return ResponseEntity.ok(massImportService.getStatus());
|
||||
}
|
||||
|
||||
@PostMapping("/backfill-versions")
|
||||
public ResponseEntity<BackfillResult> backfillVersions() {
|
||||
int count = documentVersionService.backfillMissingVersions(
|
||||
documentService.getDocumentsWithoutVersions());
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/backfill-file-hashes")
|
||||
public ResponseEntity<BackfillResult> backfillFileHashes() {
|
||||
int count = documentService.backfillFileHashes();
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/documents/{documentId}/annotations")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AnnotationController {
|
||||
|
||||
private final AnnotationService annotationService;
|
||||
private final DocumentService documentService;
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public List<DocumentAnnotation> listAnnotations(@PathVariable UUID documentId) {
|
||||
return annotationService.listAnnotations(documentId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentAnnotation createAnnotation(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody CreateAnnotationDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = resolveUserId(authentication);
|
||||
Document doc = documentService.getDocumentById(documentId);
|
||||
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{annotationId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public void deleteAnnotation(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID annotationId,
|
||||
Authentication authentication) {
|
||||
UUID userId = resolveUserId(authentication);
|
||||
annotationService.deleteAnnotation(documentId, annotationId, userId);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private UUID resolveUserId(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||
try {
|
||||
AppUser user = userService.findByUsername(authentication.getName());
|
||||
return user != null ? user.getId() : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not resolve user for annotation: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
|
||||
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.service.PasswordResetService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final PasswordResetService passwordResetService;
|
||||
|
||||
@Value("${app.base-url:http://localhost:3000}")
|
||||
private String appBaseUrl;
|
||||
|
||||
@PostMapping("/forgot-password")
|
||||
public ResponseEntity<Void> forgotPassword(@RequestBody ForgotPasswordRequest request) {
|
||||
passwordResetService.requestReset(request.getEmail(), appBaseUrl);
|
||||
// Always return 204 — never disclose whether the email exists
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/reset-password")
|
||||
public ResponseEntity<Void> resetPassword(@RequestBody ResetPasswordRequest request) {
|
||||
passwordResetService.resetPassword(request);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Test-only endpoint to retrieve a password reset token by email.
|
||||
* Only active under the "e2e" Spring profile.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@Profile("e2e")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthE2EController {
|
||||
|
||||
private final PasswordResetTokenRepository tokenRepository;
|
||||
|
||||
@GetMapping("/reset-token-for-test")
|
||||
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
|
||||
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateCommentDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.CommentService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CommentController {
|
||||
|
||||
private final CommentService commentService;
|
||||
private final UserService userService;
|
||||
|
||||
// ─── General document comments ────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/api/documents/{documentId}/comments")
|
||||
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
|
||||
return commentService.getCommentsForDocument(documentId);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/comments")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment postDocumentComment(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.postComment(documentId, null, dto.getContent(), author);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment replyToDocumentComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
||||
}
|
||||
|
||||
// ─── Annotation comments ──────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
|
||||
return commentService.getCommentsForAnnotation(annotationId);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment postAnnotationComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID annotationId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment replyToAnnotationComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
||||
}
|
||||
|
||||
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
||||
|
||||
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment editComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser currentUser = resolveUser(authentication);
|
||||
return commentService.editComment(documentId, commentId, dto.getContent(), currentUser);
|
||||
}
|
||||
|
||||
@DeleteMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
Authentication authentication) {
|
||||
AppUser currentUser = resolveUser(authentication);
|
||||
commentService.deleteComment(documentId, commentId, currentUser);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private AppUser resolveUser(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||
try {
|
||||
return userService.findByUsername(authentication.getName());
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not resolve user for comment: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,24 @@ import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.FileService;
|
||||
import org.springframework.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 +43,22 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final DocumentService documentService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
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 +75,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 +94,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);
|
||||
@@ -115,24 +114,26 @@ public class DocumentController {
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags));
|
||||
}
|
||||
|
||||
// --- VERSIONS ---
|
||||
|
||||
@GetMapping("/{id}/versions")
|
||||
public List<DocumentVersionSummary> getVersions(@PathVariable UUID id) {
|
||||
return documentVersionService.getSummaries(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/versions/{versionId}")
|
||||
public DocumentVersion getVersion(@PathVariable UUID id, @PathVariable UUID versionId) {
|
||||
return documentVersionService.getVersion(id, versionId);
|
||||
}
|
||||
|
||||
@GetMapping("/conversation")
|
||||
public List<Document> getConversation(
|
||||
@RequestParam UUID senderId,
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,57 +1,75 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/persons")
|
||||
@RequiredArgsConstructor
|
||||
public class PersonController {
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
private final DocumentRepository documentRepository;
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Person>> getPersons(@RequestParam(required = false) String q) {
|
||||
if (q != null && !q.isBlank()) {
|
||||
return ResponseEntity.ok(personRepository.searchByName(q));
|
||||
}
|
||||
return ResponseEntity.ok(personRepository.findAllByOrderByLastNameAscFirstNameAsc());
|
||||
return ResponseEntity.ok(personService.findAll(q));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Person getPerson(@PathVariable UUID id) {
|
||||
return personRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
||||
return personService.getById(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/correspondents")
|
||||
public ResponseEntity<List<Person>> getCorrespondents(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam(required = false) String q) {
|
||||
return ResponseEntity.ok(personService.findCorrespondents(id, q));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/documents")
|
||||
public List<Document> getPersonDocuments(@PathVariable UUID id) {
|
||||
return documentRepository.findBySenderId(id);
|
||||
return documentService.getDocumentsBySender(id);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
|
||||
@GetMapping("/{id}/received-documents")
|
||||
public List<Document> getPersonReceivedDocuments(@PathVariable UUID id) {
|
||||
return documentService.getDocumentsByReceiver(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
|
||||
String firstName = body.get("firstName");
|
||||
String lastName = body.get("lastName");
|
||||
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias")));
|
||||
return ResponseEntity.ok(personService.createPerson(firstName.trim(), lastName.trim(), body.get("alias")));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) {
|
||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/merge")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
@@ -16,8 +19,10 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -33,13 +38,32 @@ public class UserController {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
// Fetch full user object from DB to get latest permissions/groups
|
||||
AppUser user = userService.findByUsername(authentication.getName());
|
||||
|
||||
// Security: Remove password before sending
|
||||
user.setPassword(null);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
@PutMapping("users/me")
|
||||
public ResponseEntity<AppUser> updateProfile(Authentication authentication,
|
||||
@RequestBody UpdateProfileDTO dto) {
|
||||
AppUser current = userService.findByUsername(authentication.getName());
|
||||
AppUser updated = userService.updateProfile(current.getId(), dto);
|
||||
updated.setPassword(null);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@PostMapping("users/me/password")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void changePassword(Authentication authentication,
|
||||
@RequestBody ChangePasswordDTO dto) {
|
||||
AppUser current = userService.findByUsername(authentication.getName());
|
||||
userService.changePassword(current.getId(), dto);
|
||||
}
|
||||
|
||||
@GetMapping("users/{id}")
|
||||
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {
|
||||
AppUser user = userService.getById(id);
|
||||
user.setPassword(null);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
@@ -56,6 +80,15 @@ public class UserController {
|
||||
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
||||
}
|
||||
|
||||
@PutMapping("/users/{id}")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
|
||||
@RequestBody AdminUpdateUserRequest dto) {
|
||||
AppUser updated = userService.adminUpdateUser(id, dto);
|
||||
updated.setPassword(null);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{id}")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
public class AdminUpdateUserRequest {
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private LocalDate birthDate;
|
||||
private String email;
|
||||
private String contact;
|
||||
private String newPassword;
|
||||
private List<UUID> groupIds;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BackfillResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ChangePasswordDTO {
|
||||
private String currentPassword;
|
||||
private String newPassword;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateAnnotationDTO {
|
||||
private int pageNumber;
|
||||
private double x;
|
||||
private double y;
|
||||
private double width;
|
||||
private double height;
|
||||
private String color;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreateCommentDTO {
|
||||
private String content;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -11,5 +12,9 @@ public class CreateUserRequest {
|
||||
private String username;
|
||||
private String email;
|
||||
private String initialPassword;
|
||||
private List<UUID> groupIds; // In welche Gruppen soll der User?
|
||||
private List<UUID> groupIds;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private LocalDate birthDate;
|
||||
private String contact;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DocumentVersionSummary(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime savedAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String editorName,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<String> changedFields
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ForgotPasswordRequest {
|
||||
private String email;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PersonUpdateDTO {
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String alias;
|
||||
private String notes;
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ResetPasswordRequest {
|
||||
private String token;
|
||||
private String newPassword;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class UpdateProfileDTO {
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private LocalDate birthDate;
|
||||
private String email;
|
||||
private String contact;
|
||||
}
|
||||
@@ -43,6 +43,10 @@ public class DomainException extends RuntimeException {
|
||||
return new DomainException(code, HttpStatus.CONFLICT, message);
|
||||
}
|
||||
|
||||
public static DomainException badRequest(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
public static DomainException internal(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ public enum ErrorCode {
|
||||
// --- Users ---
|
||||
/** A user with the given ID or username does not exist. 404 */
|
||||
USER_NOT_FOUND,
|
||||
/** The supplied email address is already used by another account. 409 */
|
||||
EMAIL_ALREADY_IN_USE,
|
||||
/** The supplied current password does not match the stored hash. 400 */
|
||||
WRONG_CURRENT_PASSWORD,
|
||||
|
||||
// --- Import ---
|
||||
/** A mass import is already in progress; only one can run at a time. 409 */
|
||||
@@ -31,6 +35,18 @@ public enum ErrorCode {
|
||||
UNAUTHORIZED,
|
||||
/** The authenticated user lacks the required permission. 403 */
|
||||
FORBIDDEN,
|
||||
/** The password-reset token is missing, expired, or already used. 400 */
|
||||
INVALID_RESET_TOKEN,
|
||||
|
||||
// --- Annotations ---
|
||||
/** The annotation with the given ID does not exist. 404 */
|
||||
ANNOTATION_NOT_FOUND,
|
||||
/** The new annotation overlaps an existing one on the same page. 409 */
|
||||
ANNOTATION_OVERLAP,
|
||||
|
||||
// --- Comments ---
|
||||
/** The comment with the given ID does not exist. 404 */
|
||||
COMMENT_NOT_FOUND,
|
||||
|
||||
// --- Generic ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
@@ -36,8 +37,16 @@ public class AppUser {
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
private String password; // Wird verschlüsselt gespeichert (BCrypt)
|
||||
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Column(unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String contact;
|
||||
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
|
||||
|
||||
@@ -39,6 +39,10 @@ public class Document {
|
||||
@Column(name = "content_type")
|
||||
private String contentType;
|
||||
|
||||
// SHA-256 hash of the uploaded file — used to link annotations to a file version
|
||||
@Column(name = "file_hash", length = 64)
|
||||
private String fileHash;
|
||||
|
||||
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||
@Column(name = "original_filename", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "document_annotations")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DocumentAnnotation {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "document_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID documentId;
|
||||
|
||||
@Column(name = "page_number", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int pageNumber;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double x;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double y;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double width;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double height;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String color;
|
||||
|
||||
@Column(name = "file_hash", length = 64)
|
||||
private String fileHash;
|
||||
|
||||
@Column(name = "created_by")
|
||||
private UUID createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "document_comments")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DocumentComment {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "document_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID documentId;
|
||||
|
||||
@Column(name = "annotation_id")
|
||||
private UUID annotationId;
|
||||
|
||||
@Column(name = "parent_id")
|
||||
private UUID parentId;
|
||||
|
||||
@Column(name = "author_id")
|
||||
private UUID authorId;
|
||||
|
||||
@Column(name = "author_name", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String authorName;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String content;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
@UpdateTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// Populated by the service — not stored in the database
|
||||
@Transient
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<DocumentComment> replies = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "document_versions")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DocumentVersion {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "document_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID documentId;
|
||||
|
||||
@Column(name = "saved_at", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime savedAt;
|
||||
|
||||
@Column(name = "editor_id")
|
||||
private UUID editorId;
|
||||
|
||||
@Column(name = "editor_name", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String editorName;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(columnDefinition = "jsonb", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String snapshot;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "changed_fields", columnDefinition = "jsonb", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String changedFields;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Table(name = "password_reset_tokens")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PasswordResetToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private AppUser user;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
private String token;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private boolean used = false;
|
||||
}
|
||||
@@ -28,4 +28,11 @@ public class Person {
|
||||
|
||||
// Optional: Aliasse für die Suche (z.B. "Opa Hans")
|
||||
private String alias;
|
||||
|
||||
// Optional: Free-text biographical notes
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String notes;
|
||||
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AnnotationRepository extends JpaRepository<DocumentAnnotation, UUID> {
|
||||
|
||||
List<DocumentAnnotation> findByDocumentId(UUID documentId);
|
||||
|
||||
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
|
||||
|
||||
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||
|
||||
List<DocumentAnnotation> findByDocumentIdAndFileHashIsNull(UUID documentId);
|
||||
}
|
||||
@@ -11,4 +11,5 @@ import java.util.UUID;
|
||||
@Repository
|
||||
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
||||
Optional<AppUser> findByUsername(String username);
|
||||
Optional<AppUser> findByEmail(String email);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
|
||||
|
||||
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
|
||||
|
||||
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
|
||||
|
||||
List<DocumentComment> findByParentId(UUID parentId);
|
||||
}
|
||||
@@ -30,8 +30,15 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
List<Document> findBySenderId(UUID senderId);
|
||||
|
||||
List<Document> findByReceiversId(UUID receiverId);
|
||||
|
||||
List<Document> findByTags_Id(UUID tagId);
|
||||
|
||||
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||
List<Document> findDocumentsWithoutVersions();
|
||||
|
||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"JOIN d.receivers r " +
|
||||
"WHERE " +
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface DocumentVersionRepository extends JpaRepository<DocumentVersion, UUID> {
|
||||
|
||||
List<DocumentVersion> findByDocumentIdOrderBySavedAtAsc(UUID documentId);
|
||||
|
||||
Optional<DocumentVersion> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, UUID> {
|
||||
|
||||
Optional<PasswordResetToken> findByToken(String token);
|
||||
|
||||
@Query("SELECT t.token FROM PasswordResetToken t WHERE t.user.email = :email AND t.used = false AND t.expiresAt > :now ORDER BY t.expiresAt DESC LIMIT 1")
|
||||
Optional<String> findLatestActiveTokenByEmail(String email, LocalDateTime now);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM PasswordResetToken t WHERE t.expiresAt < :now OR t.used = true")
|
||||
void deleteExpiredAndUsed(LocalDateTime now);
|
||||
}
|
||||
@@ -28,6 +28,51 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
|
||||
// --- Correspondent queries ---
|
||||
|
||||
@Query(value = """
|
||||
SELECT p.* FROM persons p
|
||||
INNER JOIN (
|
||||
SELECT dr.person_id AS other_id, d.id AS doc_id
|
||||
FROM documents d
|
||||
JOIN document_receivers dr ON dr.document_id = d.id
|
||||
WHERE d.sender_id = :personId
|
||||
UNION ALL
|
||||
SELECT d.sender_id AS other_id, d.id AS doc_id
|
||||
FROM documents d
|
||||
JOIN document_receivers dr ON dr.document_id = d.id
|
||||
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
|
||||
) shared ON shared.other_id = p.id
|
||||
WHERE p.id != :personId
|
||||
GROUP BY p.id
|
||||
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
|
||||
LIMIT 10
|
||||
""", nativeQuery = true)
|
||||
List<Person> findCorrespondents(@Param("personId") UUID personId);
|
||||
|
||||
@Query(value = """
|
||||
SELECT p.* FROM persons p
|
||||
INNER JOIN (
|
||||
SELECT dr.person_id AS other_id, d.id AS doc_id
|
||||
FROM documents d
|
||||
JOIN document_receivers dr ON dr.document_id = d.id
|
||||
WHERE d.sender_id = :personId
|
||||
UNION ALL
|
||||
SELECT d.sender_id AS other_id, d.id AS doc_id
|
||||
FROM documents d
|
||||
JOIN document_receivers dr ON dr.document_id = d.id
|
||||
WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL
|
||||
) shared ON shared.other_id = p.id
|
||||
WHERE p.id != :personId
|
||||
AND (LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%'))
|
||||
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:q,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:q,'%')))
|
||||
GROUP BY p.id
|
||||
ORDER BY COUNT(DISTINCT shared.doc_id) DESC
|
||||
LIMIT 10
|
||||
""", nativeQuery = true)
|
||||
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
||||
|
||||
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
||||
|
||||
@Modifying
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.security;
|
||||
public enum Permission {
|
||||
READ_ALL,
|
||||
WRITE_ALL,
|
||||
ANNOTATE_ALL,
|
||||
ADMIN,
|
||||
ADMIN_USER,
|
||||
ADMIN_TAG,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnnotationService {
|
||||
|
||||
private final AnnotationRepository annotationRepository;
|
||||
|
||||
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
|
||||
return annotationRepository.findByDocumentId(documentId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
|
||||
List<DocumentAnnotation> existing =
|
||||
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
||||
|
||||
boolean overlaps = existing.stream().anyMatch(a -> overlaps(a, dto));
|
||||
if (overlaps) {
|
||||
throw DomainException.conflict(
|
||||
ErrorCode.ANNOTATION_OVERLAP, "Annotation overlaps an existing one on this page");
|
||||
}
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||
.documentId(documentId)
|
||||
.pageNumber(dto.getPageNumber())
|
||||
.x(dto.getX())
|
||||
.y(dto.getY())
|
||||
.width(dto.getWidth())
|
||||
.height(dto.getHeight())
|
||||
.color(dto.getColor())
|
||||
.fileHash(fileHash)
|
||||
.createdBy(userId)
|
||||
.build();
|
||||
|
||||
return annotationRepository.save(annotation);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) {
|
||||
DocumentAnnotation annotation = annotationRepository
|
||||
.findByIdAndDocumentId(annotationId, documentId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId));
|
||||
|
||||
if (userId == null || !userId.equals(annotation.getCreatedBy())) {
|
||||
throw DomainException.forbidden("Only the annotation author can delete it");
|
||||
}
|
||||
|
||||
annotationRepository.delete(annotation);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void backfillAnnotationFileHashForDocument(UUID documentId, String fileHash) {
|
||||
annotationRepository.findByDocumentIdAndFileHashIsNull(documentId).forEach(a -> {
|
||||
a.setFileHash(fileHash);
|
||||
annotationRepository.save(a);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
|
||||
double ex2 = existing.getX() + existing.getWidth();
|
||||
double ey2 = existing.getY() + existing.getHeight();
|
||||
double dx2 = dto.getX() + dto.getWidth();
|
||||
double dy2 = dto.getY() + dto.getHeight();
|
||||
return existing.getX() < dx2 && ex2 > dto.getX()
|
||||
&& existing.getY() < dy2 && ey2 > dto.getY();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CommentService {
|
||||
|
||||
private final CommentRepository commentRepository;
|
||||
|
||||
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||
List<DocumentComment> roots =
|
||||
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
||||
return withReplies(roots);
|
||||
}
|
||||
|
||||
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
||||
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
||||
return withReplies(roots);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.documentId(documentId)
|
||||
.annotationId(annotationId)
|
||||
.content(content)
|
||||
.authorId(author.getId())
|
||||
.authorName(resolveAuthorName(author))
|
||||
.build();
|
||||
return commentRepository.save(comment);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
|
||||
DocumentComment target = commentRepository.findById(commentId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
||||
|
||||
UUID rootId = target.getParentId() != null ? target.getParentId() : target.getId();
|
||||
DocumentComment root = commentRepository.findById(rootId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + rootId));
|
||||
|
||||
DocumentComment reply = DocumentComment.builder()
|
||||
.documentId(documentId)
|
||||
.annotationId(root.getAnnotationId())
|
||||
.parentId(root.getId())
|
||||
.content(content)
|
||||
.authorId(author.getId())
|
||||
.authorName(resolveAuthorName(author))
|
||||
.build();
|
||||
return commentRepository.save(reply);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentComment editComment(UUID documentId, UUID commentId, String content, AppUser currentUser) {
|
||||
DocumentComment comment = findComment(documentId, commentId);
|
||||
if (!currentUser.getId().equals(comment.getAuthorId())) {
|
||||
throw DomainException.forbidden("Only the comment author can edit it");
|
||||
}
|
||||
comment.setContent(content);
|
||||
return commentRepository.save(comment);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteComment(UUID documentId, UUID commentId, AppUser currentUser) {
|
||||
DocumentComment comment = findComment(documentId, commentId);
|
||||
boolean isAuthor = currentUser.getId().equals(comment.getAuthorId());
|
||||
boolean isAdmin = currentUser.hasPermission("ADMIN");
|
||||
if (!isAuthor && !isAdmin) {
|
||||
throw DomainException.forbidden("Only the comment author or an admin can delete it");
|
||||
}
|
||||
commentRepository.delete(comment);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
|
||||
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
|
||||
return roots;
|
||||
}
|
||||
|
||||
private DocumentComment findComment(UUID documentId, UUID commentId) {
|
||||
return commentRepository.findById(commentId)
|
||||
.filter(c -> documentId.equals(c.getDocumentId()))
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
||||
}
|
||||
|
||||
private String resolveAuthorName(AppUser author) {
|
||||
String first = author.getFirstName();
|
||||
String last = author.getLastName();
|
||||
if ((first == null || first.isBlank()) && (last == null || last.isBlank())) {
|
||||
return author.getUsername();
|
||||
}
|
||||
return ((first != null ? first : "") + " " + (last != null ? last : "")).strip();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -20,6 +18,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -31,14 +31,16 @@ import java.util.UUID;
|
||||
import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor // Lombok: Erzeugt Constructor für 'final' Felder (Dependency Injection)
|
||||
@Slf4j // Lombok: Logging
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DocumentService {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final PersonRepository personRepository;
|
||||
private final PersonService personService;
|
||||
private final FileService fileService;
|
||||
private final TagRepository tagRepository;
|
||||
private final TagService tagService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
private final AnnotationService annotationService;
|
||||
|
||||
/**
|
||||
* Lädt eine Datei hoch.
|
||||
@@ -65,10 +67,11 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 2. Delegate Storage to FileService
|
||||
String s3Key = fileService.uploadFile(file, originalFilename);
|
||||
FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
|
||||
|
||||
// 3. Update Database
|
||||
document.setFilePath(s3Key);
|
||||
document.setFilePath(upload.s3Key());
|
||||
document.setFileHash(upload.fileHash());
|
||||
document.setContentType(file.getContentType());
|
||||
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
|
||||
document.setStatus(DocumentStatus.UPLOADED);
|
||||
@@ -104,28 +107,33 @@ public class DocumentService {
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
updateDocumentTags(doc.getId(), tags);
|
||||
doc = documentRepository.findById(doc.getId()).orElseThrow();
|
||||
UUID savedId = doc.getId();
|
||||
updateDocumentTags(savedId, tags);
|
||||
doc = documentRepository.findById(savedId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found after save: " + savedId));
|
||||
|
||||
// Sender
|
||||
if (dto.getSenderId() != null) {
|
||||
doc.setSender(personRepository.findById(dto.getSenderId()).orElse(null));
|
||||
doc.setSender(personService.getById(dto.getSenderId()));
|
||||
}
|
||||
|
||||
// Empfänger
|
||||
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
|
||||
doc.setReceivers(new HashSet<>(personRepository.findAllById(dto.getReceiverIds())));
|
||||
doc.setReceivers(new HashSet<>(personService.getAllById(dto.getReceiverIds())));
|
||||
}
|
||||
|
||||
// Datei
|
||||
if (file != null && !file.isEmpty()) {
|
||||
String s3Key = fileService.uploadFile(file, file.getOriginalFilename());
|
||||
doc.setFilePath(s3Key);
|
||||
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
||||
doc.setFilePath(upload.s3Key());
|
||||
doc.setFileHash(upload.fileHash());
|
||||
doc.setContentType(file.getContentType());
|
||||
doc.setStatus(DocumentStatus.UPLOADED);
|
||||
}
|
||||
|
||||
return documentRepository.save(doc);
|
||||
Document finalDoc = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(finalDoc);
|
||||
return finalDoc;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -153,39 +161,36 @@ 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
|
||||
}
|
||||
|
||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||
if (newFile != null && !newFile.isEmpty()) {
|
||||
// Alte Datei könnte man hier theoretisch löschen (optional)
|
||||
|
||||
// Neue Datei hochladen
|
||||
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
|
||||
doc.setFilePath(s3Key);
|
||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
doc.setFilePath(upload.s3Key());
|
||||
doc.setFileHash(upload.fileHash());
|
||||
doc.setOriginalFilename(newFile.getOriginalFilename());
|
||||
doc.setContentType(newFile.getContentType());
|
||||
doc.setStatus(DocumentStatus.UPLOADED);
|
||||
}
|
||||
|
||||
return documentRepository.save(doc);
|
||||
Document saved = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||
Document doc = documentRepository.findById(docId).orElseThrow();
|
||||
Document doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||
|
||||
Set<Tag> newTags = new HashSet<>();
|
||||
|
||||
@@ -195,11 +200,7 @@ public class DocumentService {
|
||||
if (cleanName.isEmpty())
|
||||
continue;
|
||||
|
||||
// Find existing or Create new
|
||||
Tag tag = tagRepository.findByNameIgnoreCase(cleanName)
|
||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
||||
|
||||
newTags.add(tag);
|
||||
newTags.add(tagService.findOrCreate(cleanName));
|
||||
}
|
||||
|
||||
doc.setTags(newTags);
|
||||
@@ -226,12 +227,11 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID reciever, List<String> tags) {
|
||||
log.info("Tags", tags);
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags) {
|
||||
Specification<Document> spec = Specification.where(hasText(text))
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(reciever))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(tags));
|
||||
|
||||
// Immer sortiert nach Datum
|
||||
@@ -253,4 +253,71 @@ 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> getDocumentsWithoutVersions() {
|
||||
return documentRepository.findDocumentsWithoutVersions();
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||
return documentRepository.findBySenderId(senderId);
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsByReceiver(UUID receiverId) {
|
||||
return documentRepository.findByReceiversId(receiverId);
|
||||
}
|
||||
|
||||
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
||||
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);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int backfillFileHashes() {
|
||||
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
||||
int count = 0;
|
||||
for (Document doc : docs) {
|
||||
try {
|
||||
byte[] bytes = fileService.downloadFileBytes(doc.getFilePath());
|
||||
String hash = sha256Hex(bytes);
|
||||
doc.setFileHash(hash);
|
||||
documentRepository.save(doc);
|
||||
annotationService.backfillAnnotationFileHashForDocument(doc.getId(), hash);
|
||||
count++;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to backfill hash for document {}: {}", doc.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(bytes);
|
||||
StringBuilder sb = new StringBuilder(64);
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentVersionRepository;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DocumentVersionService {
|
||||
|
||||
private final DocumentVersionRepository versionRepository;
|
||||
private final UserService userService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public void recordVersion(Document doc) {
|
||||
AppUser editor = resolveCurrentUser();
|
||||
String editorName = buildEditorName(editor);
|
||||
UUID editorId = editor != null ? editor.getId() : null;
|
||||
|
||||
String snapshot = serializeSnapshot(doc);
|
||||
List<DocumentVersion> previous = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId());
|
||||
String changedFields = computeChangedFields(doc, previous);
|
||||
|
||||
versionRepository.save(DocumentVersion.builder()
|
||||
.documentId(doc.getId())
|
||||
.savedAt(LocalDateTime.now())
|
||||
.editorId(editorId)
|
||||
.editorName(editorName)
|
||||
.snapshot(snapshot)
|
||||
.changedFields(changedFields)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int backfillMissingVersions(List<Document> docs) {
|
||||
int count = 0;
|
||||
for (Document doc : docs) {
|
||||
List<DocumentVersion> existing = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId());
|
||||
if (!existing.isEmpty()) continue;
|
||||
LocalDateTime savedAt = doc.getCreatedAt() != null ? doc.getCreatedAt() : LocalDateTime.now();
|
||||
versionRepository.save(DocumentVersion.builder()
|
||||
.documentId(doc.getId())
|
||||
.savedAt(savedAt)
|
||||
.editorId(null)
|
||||
.editorName("Datenimport")
|
||||
.snapshot(serializeSnapshot(doc))
|
||||
.changedFields("[]")
|
||||
.build());
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public List<DocumentVersionSummary> getSummaries(UUID documentId) {
|
||||
return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream()
|
||||
.map(v -> new DocumentVersionSummary(
|
||||
v.getId(),
|
||||
v.getSavedAt(),
|
||||
v.getEditorName(),
|
||||
parseChangedFields(v.getChangedFields())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public DocumentVersion getVersion(UUID documentId, UUID versionId) {
|
||||
return versionRepository.findByIdAndDocumentId(versionId, documentId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND,
|
||||
"Version not found: " + versionId));
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private AppUser resolveCurrentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return userService.findByUsername(auth.getName());
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not resolve editor for version snapshot: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildEditorName(AppUser user) {
|
||||
if (user == null) return "Unknown";
|
||||
String first = user.getFirstName();
|
||||
String last = user.getLastName();
|
||||
if (first != null && !first.isBlank() && last != null && !last.isBlank()) {
|
||||
return first + " " + last;
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
private String serializeSnapshot(Document doc) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(doc);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to serialize document snapshot for {}", doc.getId(), e);
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
private String computeChangedFields(Document current, List<DocumentVersion> previousVersions) {
|
||||
if (previousVersions.isEmpty()) {
|
||||
return "[]";
|
||||
}
|
||||
DocumentVersion last = previousVersions.get(previousVersions.size() - 1);
|
||||
try {
|
||||
Map<String, Object> previousMap = objectMapper.readValue(
|
||||
last.getSnapshot(), new TypeReference<>() {});
|
||||
List<String> changed = new ArrayList<>();
|
||||
|
||||
checkScalar(changed, "title", current.getTitle(), previousMap);
|
||||
checkScalar(changed, "documentDate",
|
||||
current.getDocumentDate() != null ? current.getDocumentDate().toString() : null,
|
||||
previousMap);
|
||||
checkScalar(changed, "location", current.getLocation(), previousMap);
|
||||
checkScalar(changed, "documentLocation", current.getDocumentLocation(), previousMap);
|
||||
checkScalar(changed, "transcription", current.getTranscription(), previousMap);
|
||||
checkScalar(changed, "summary", current.getSummary(), previousMap);
|
||||
checkSender(changed, current, previousMap);
|
||||
checkReceivers(changed, current, previousMap);
|
||||
checkTags(changed, current, previousMap);
|
||||
|
||||
return objectMapper.writeValueAsString(changed);
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not compute changedFields for document {}", current.getId(), e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
private void checkScalar(List<String> changed, String field, String currentValue,
|
||||
Map<String, Object> previousMap) {
|
||||
Object prev = previousMap.get(field);
|
||||
String prevStr = prev != null ? prev.toString() : null;
|
||||
if (!Objects.equals(currentValue, prevStr)) {
|
||||
changed.add(field);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void checkSender(List<String> changed, Document current, Map<String, Object> previousMap) {
|
||||
String currentId = current.getSender() != null
|
||||
? current.getSender().getId().toString() : null;
|
||||
Object prevSender = previousMap.get("sender");
|
||||
String prevId = null;
|
||||
if (prevSender instanceof Map<?, ?> senderMap) {
|
||||
Object id = senderMap.get("id");
|
||||
prevId = id != null ? id.toString() : null;
|
||||
}
|
||||
if (!Objects.equals(currentId, prevId)) {
|
||||
changed.add("sender");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void checkReceivers(List<String> changed, Document current, Map<String, Object> previousMap) {
|
||||
Set<String> currentIds = current.getReceivers() != null
|
||||
? current.getReceivers().stream().map(p -> p.getId().toString()).collect(Collectors.toSet())
|
||||
: Set.of();
|
||||
Object prevReceivers = previousMap.get("receivers");
|
||||
Set<String> prevIds = Set.of();
|
||||
if (prevReceivers instanceof List<?> list) {
|
||||
prevIds = list.stream()
|
||||
.filter(r -> r instanceof Map<?, ?>)
|
||||
.map(r -> ((Map<?, ?>) r).get("id"))
|
||||
.filter(Objects::nonNull)
|
||||
.map(Object::toString)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
if (!currentIds.equals(prevIds)) {
|
||||
changed.add("receivers");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void checkTags(List<String> changed, Document current, Map<String, Object> previousMap) {
|
||||
Set<String> currentNames = current.getTags() != null
|
||||
? current.getTags().stream().map(Tag::getName).collect(Collectors.toSet())
|
||||
: Set.of();
|
||||
Object prevTags = previousMap.get("tags");
|
||||
Set<String> prevNames = Set.of();
|
||||
if (prevTags instanceof List<?> list) {
|
||||
prevNames = list.stream()
|
||||
.filter(t -> t instanceof Map<?, ?>)
|
||||
.map(t -> ((Map<?, ?>) t).get("name"))
|
||||
.filter(Objects::nonNull)
|
||||
.map(Object::toString)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
if (!currentNames.equals(prevNames)) {
|
||||
changed.add("tags");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseChangedFields(String json) {
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@@ -29,10 +32,14 @@ public class FileService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to S3/MinIO and returns the generated object key.
|
||||
* Uploads a file to S3/MinIO.
|
||||
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
|
||||
* hash of the file content. The hash is used to link annotations to the
|
||||
* specific file version they were created against.
|
||||
*/
|
||||
public String uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
||||
// Generate secure unique path: "documents/UUID_filename"
|
||||
public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
||||
byte[] bytes = file.getBytes();
|
||||
String fileHash = sha256Hex(bytes);
|
||||
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
||||
|
||||
try {
|
||||
@@ -42,11 +49,10 @@ public class FileService {
|
||||
.contentType(file.getContentType())
|
||||
.build();
|
||||
|
||||
s3Client.putObject(putObjectRequest,
|
||||
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
|
||||
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
|
||||
|
||||
log.info("Uploaded file to S3: {}", s3Key);
|
||||
return s3Key;
|
||||
log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
|
||||
return new UploadResult(s3Key, fileHash);
|
||||
} catch (S3Exception e) {
|
||||
log.error("S3 Upload Error", e);
|
||||
throw new IOException("Failed to upload file to storage", e);
|
||||
@@ -58,32 +64,72 @@ public class FileService {
|
||||
* Returns a wrapper containing the stream and content type.
|
||||
*/
|
||||
public S3FileDownload downloadFile(String s3Key) {
|
||||
try {
|
||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(s3Key)
|
||||
.build();
|
||||
try {
|
||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(s3Key)
|
||||
.build();
|
||||
|
||||
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
||||
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
||||
|
||||
// Use whatever content type S3 has stored (set at upload time)
|
||||
String contentType = s3Object.response().contentType();
|
||||
if (contentType == null || contentType.isBlank()) {
|
||||
contentType = "application/octet-stream";
|
||||
String contentType = s3Object.response().contentType();
|
||||
if (contentType == null || contentType.isBlank()) {
|
||||
contentType = "application/octet-stream";
|
||||
}
|
||||
|
||||
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
|
||||
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||
} catch (S3Exception e) {
|
||||
throw new RuntimeException("Storage Error: " + e.getMessage());
|
||||
}
|
||||
|
||||
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
|
||||
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||
} catch (S3Exception e) {
|
||||
throw new RuntimeException("Storage Error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
// Helper Record to carry the stream and metadata back to the controller
|
||||
|
||||
/**
|
||||
* Downloads a file from S3/MinIO and returns its raw bytes.
|
||||
* Used for hash backfill — callers are responsible for not calling this on large files unnecessarily.
|
||||
*/
|
||||
public byte[] downloadFileBytes(String s3Key) throws IOException {
|
||||
try {
|
||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(s3Key)
|
||||
.build();
|
||||
try (InputStream in = s3Client.getObject(getObjectRequest)) {
|
||||
return in.readAllBytes();
|
||||
}
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||
} catch (S3Exception e) {
|
||||
throw new IOException("Failed to download file from storage: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(bytes);
|
||||
StringBuilder sb = new StringBuilder(64);
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── result types ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Carries the S3 object key and the content hash back to the caller. */
|
||||
public record UploadResult(String s3Key, String fileHash) {}
|
||||
|
||||
/** Carries the download stream and content type. */
|
||||
public record S3FileDownload(InputStreamResource resource, String contentType) {}
|
||||
|
||||
// Custom Exception
|
||||
public static class StorageFileNotFoundException extends RuntimeException {
|
||||
public StorageFileNotFoundException(String message) { super(message); }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.MailException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PasswordResetService {
|
||||
|
||||
private final AppUserRepository userRepository;
|
||||
private final PasswordResetTokenRepository tokenRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
||||
private String mailFrom;
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
private static final int TOKEN_EXPIRY_HOURS = 1;
|
||||
|
||||
/**
|
||||
* Creates a reset token for the given email address and sends it via email.
|
||||
* If the email is not found, silently does nothing (prevents user enumeration).
|
||||
* If no mail sender is configured, logs a warning.
|
||||
*/
|
||||
public void requestReset(String email, String appBaseUrl) {
|
||||
Optional<AppUser> userOpt = userRepository.findByEmail(email);
|
||||
if (userOpt.isEmpty()) {
|
||||
log.debug("Password reset requested for unknown email: {}", email);
|
||||
return;
|
||||
}
|
||||
|
||||
AppUser user = userOpt.get();
|
||||
String token = generateToken();
|
||||
|
||||
tokenRepository.save(PasswordResetToken.builder()
|
||||
.user(user)
|
||||
.token(token)
|
||||
.expiresAt(LocalDateTime.now().plusHours(TOKEN_EXPIRY_HOURS))
|
||||
.build());
|
||||
|
||||
sendResetEmail(user.getEmail(), token, appBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the token and updates the user's password.
|
||||
*/
|
||||
@Transactional
|
||||
public void resetPassword(ResetPasswordRequest request) {
|
||||
PasswordResetToken resetToken = tokenRepository.findByToken(request.getToken())
|
||||
.orElseThrow(() -> DomainException.badRequest(
|
||||
ErrorCode.INVALID_RESET_TOKEN, "Invalid or unknown reset token"));
|
||||
|
||||
if (resetToken.isUsed() || resetToken.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_RESET_TOKEN, "Token expired or already used");
|
||||
}
|
||||
|
||||
AppUser user = resetToken.getUser();
|
||||
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
|
||||
userRepository.save(user);
|
||||
|
||||
resetToken.setUsed(true);
|
||||
tokenRepository.save(resetToken);
|
||||
}
|
||||
|
||||
/** Nightly cleanup of expired and used tokens. */
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
@Transactional
|
||||
public void cleanupExpiredTokens() {
|
||||
tokenRepository.deleteExpiredAndUsed(LocalDateTime.now());
|
||||
log.info("Cleaned up expired password reset tokens");
|
||||
}
|
||||
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
SECURE_RANDOM.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private void sendResetEmail(String to, String token, String appBaseUrl) {
|
||||
if (mailSender == null) {
|
||||
log.warn("Mail sender not configured — skipping password reset email to {}", to);
|
||||
return;
|
||||
}
|
||||
|
||||
String resetUrl = appBaseUrl + "/reset-password?token=" + token;
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(mailFrom);
|
||||
message.setTo(to);
|
||||
message.setSubject("Passwort zurücksetzen — Familienarchiv");
|
||||
message.setText(
|
||||
"Hallo,\n\n"
|
||||
+ "Sie haben eine Passwort-Zurücksetzung beantragt.\n\n"
|
||||
+ "Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:\n"
|
||||
+ resetUrl + "\n\n"
|
||||
+ "Der Link ist " + TOKEN_EXPIRY_HOURS + " Stunde(n) gültig.\n\n"
|
||||
+ "Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n"
|
||||
+ "Ihr Familienarchiv-Team");
|
||||
|
||||
try {
|
||||
mailSender.send(message);
|
||||
log.info("Password reset email sent to {}", to);
|
||||
} catch (MailException e) {
|
||||
log.error("Failed to send password reset email to {}: {}", to, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -8,7 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -16,13 +19,65 @@ public class PersonService {
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
|
||||
public List<Person> findAll(String q) {
|
||||
if (q != null && !q.isBlank()) {
|
||||
return personRepository.searchByName(q);
|
||||
}
|
||||
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
|
||||
public Person getById(UUID id) {
|
||||
return personRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
||||
}
|
||||
|
||||
public List<Person> findCorrespondents(UUID personId, String q) {
|
||||
if (q != null && !q.isBlank()) {
|
||||
return personRepository.findCorrespondentsWithFilter(personId, q);
|
||||
}
|
||||
return personRepository.findCorrespondents(personId);
|
||||
}
|
||||
|
||||
public List<Person> getAllById(List<UUID> ids) {
|
||||
return personRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, String firstName, String lastName, String alias) {
|
||||
public Person findOrCreateByAlias(String rawName) {
|
||||
String alias = rawName.trim();
|
||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
return personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.build());
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person createPerson(String firstName, String lastName, String alias) {
|
||||
Person person = Person.builder()
|
||||
.firstName(firstName)
|
||||
.lastName(lastName)
|
||||
.alias(alias == null || alias.isBlank() ? null : alias.trim())
|
||||
.build();
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
||||
}
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
||||
person.setFirstName(firstName);
|
||||
person.setLastName(lastName);
|
||||
person.setAlias(alias == null || alias.isBlank() ? null : alias.trim());
|
||||
person.setFirstName(dto.getFirstName());
|
||||
person.setLastName(dto.getLastName());
|
||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||
person.setBirthYear(dto.getBirthYear());
|
||||
person.setDeathYear(dto.getDeathYear());
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.dto.GroupDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
@@ -29,49 +33,121 @@ public class UserService {
|
||||
private final UserGroupRepository groupRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Transactional
|
||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||
log.info("Versuche neuen User anzulegen: {}", request.getUsername());
|
||||
@Transactional
|
||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||
log.info("Creating or updating user: {}", request.getUsername());
|
||||
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||
List<UserGroup> foundGroups = groupRepository.findAllById(request.getGroupIds());
|
||||
groups.addAll(foundGroups);
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
|
||||
}
|
||||
|
||||
Optional<AppUser> existingUser = userRepository.findByUsername(request.getUsername());
|
||||
AppUser user;
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
log.info("User exists, updating: {}", request.getUsername());
|
||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||
} else {
|
||||
log.info("Creating new user: {}", request.getUsername());
|
||||
user = AppUser.builder()
|
||||
.username(request.getUsername())
|
||||
.email(request.getEmail())
|
||||
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||
.groups(groups)
|
||||
.firstName(request.getFirstName())
|
||||
.lastName(request.getLastName())
|
||||
.birthDate(request.getBirthDate())
|
||||
.contact(request.getContact())
|
||||
.enabled(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
log.info("GroupsIds {}", groups.toString());
|
||||
log.info("Groupds in DB {}", groupRepository.findAll().toString());
|
||||
|
||||
Optional<AppUser> dbUser = userRepository.findByUsername(request.getUsername());
|
||||
AppUser user;
|
||||
|
||||
if (dbUser.isPresent()) {
|
||||
log.info("Found user in DB. Will update.");
|
||||
user = dbUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||
} else {
|
||||
log.info("Creating new user.");
|
||||
user = AppUser.builder()
|
||||
.username(request.getUsername())
|
||||
.email(request.getEmail())
|
||||
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||
.groups(groups)
|
||||
.enabled(true)
|
||||
.build();
|
||||
}
|
||||
log.info("Saving new user {}", user.toString());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
@Transactional
|
||||
public void deleteUser(UUID userId) {
|
||||
log.info("Delete user {}", userId);
|
||||
|
||||
AppUser user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for id %s", userId)));
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
public AppUser getById(UUID id) {
|
||||
return userRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
||||
AppUser user = getById(userId);
|
||||
|
||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||
userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
|
||||
if (!existing.getId().equals(userId)) {
|
||||
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE,
|
||||
"E-Mail wird bereits von einem anderen Konto verwendet");
|
||||
}
|
||||
});
|
||||
user.setEmail(dto.getEmail().trim());
|
||||
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) {
|
||||
user.setEmail(null);
|
||||
}
|
||||
|
||||
user.setFirstName(dto.getFirstName());
|
||||
user.setLastName(dto.getLastName());
|
||||
user.setBirthDate(dto.getBirthDate());
|
||||
user.setContact(dto.getContact() == null || dto.getContact().isBlank() ? null : dto.getContact().trim());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
|
||||
AppUser user = getById(id);
|
||||
|
||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||
userRepository.findByEmail(dto.getEmail()).ifPresent(existing -> {
|
||||
if (!existing.getId().equals(id)) {
|
||||
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE,
|
||||
"E-Mail wird bereits von einem anderen Konto verwendet");
|
||||
}
|
||||
});
|
||||
user.setEmail(dto.getEmail().trim());
|
||||
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) {
|
||||
user.setEmail(null);
|
||||
}
|
||||
|
||||
user.setFirstName(dto.getFirstName());
|
||||
user.setLastName(dto.getLastName());
|
||||
user.setBirthDate(dto.getBirthDate());
|
||||
user.setContact(dto.getContact() == null || dto.getContact().isBlank() ? null : dto.getContact().trim());
|
||||
|
||||
if (dto.getNewPassword() != null && !dto.getNewPassword().isBlank()) {
|
||||
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
|
||||
}
|
||||
|
||||
if (dto.getGroupIds() != null) {
|
||||
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||
user.setGroups(groups);
|
||||
}
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
||||
AppUser user = getById(userId);
|
||||
if (!passwordEncoder.matches(dto.getCurrentPassword(), user.getPassword())) {
|
||||
throw DomainException.badRequest(ErrorCode.WRONG_CURRENT_PASSWORD,
|
||||
"Das aktuelle Passwort ist falsch");
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public AppUser findByUsername(String username) {
|
||||
return userRepository.findByUsername(username).orElseThrow(
|
||||
() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for username %s", username)));
|
||||
return userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for username: " + username));
|
||||
}
|
||||
|
||||
public List<AppUser> getAllUsers() {
|
||||
@@ -81,4 +157,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ spring:
|
||||
password: ${SPRING_DATASOURCE_PASSWORD}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
flyway:
|
||||
enabled: false # Managed explicitly via FlywayConfig bean
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
@@ -21,6 +24,23 @@ spring:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
|
||||
mail:
|
||||
host: ${MAIL_HOST:}
|
||||
port: ${MAIL_PORT:587}
|
||||
username: ${MAIL_USERNAME:}
|
||||
password: ${MAIL_PASSWORD:}
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
|
||||
management:
|
||||
health:
|
||||
mail:
|
||||
enabled: false
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
@@ -35,6 +55,11 @@ app:
|
||||
bucket: ${S3_BUCKET_NAME}
|
||||
region: ${S3_REGION}
|
||||
|
||||
base-url: ${APP_BASE_URL:http://localhost:3000}
|
||||
|
||||
mail:
|
||||
from: ${APP_MAIL_FROM:noreply@familienarchiv.local}
|
||||
|
||||
admin:
|
||||
username: ${APP_ADMIN_USERNAME:admin}
|
||||
password: ${APP_ADMIN_PASSWORD:admin123}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE document_annotations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
page_number INTEGER NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL,
|
||||
y DOUBLE PRECISION NOT NULL,
|
||||
width DOUBLE PRECISION NOT NULL,
|
||||
height DOUBLE PRECISION NOT NULL,
|
||||
color VARCHAR(20) NOT NULL,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ON document_annotations (document_id, page_number);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Grant ANNOTATE_ALL to every group that already has ADMIN.
|
||||
-- New installs get it via DataInitializer; this covers existing deployments.
|
||||
INSERT INTO group_permissions (group_id, permission)
|
||||
SELECT g.id, 'ANNOTATE_ALL'
|
||||
FROM user_groups g
|
||||
WHERE g.id IN (SELECT group_id FROM group_permissions WHERE permission = 'ADMIN')
|
||||
AND g.id NOT IN (SELECT group_id FROM group_permissions WHERE permission = 'ANNOTATE_ALL');
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE document_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
annotation_id UUID REFERENCES document_annotations(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES document_comments(id) ON DELETE CASCADE,
|
||||
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
author_name VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dc_document ON document_comments(document_id);
|
||||
CREATE INDEX idx_dc_annotation ON document_comments(annotation_id);
|
||||
CREATE INDEX idx_dc_parent ON document_comments(parent_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Add content-based file hash to documents for annotation versioning
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN file_hash VARCHAR(64);
|
||||
|
||||
-- Each annotation remembers which file version it was created against
|
||||
ALTER TABLE document_annotations
|
||||
ADD COLUMN file_hash VARCHAR(64);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS birth_year INTEGER;
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS death_year INTEGER;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users ADD COLUMN first_name VARCHAR(100);
|
||||
ALTER TABLE users ADD COLUMN last_name VARCHAR(100);
|
||||
ALTER TABLE users ADD COLUMN birth_date DATE;
|
||||
ALTER TABLE users ADD COLUMN contact TEXT;
|
||||
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE password_reset_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_prt_token ON password_reset_tokens(token);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE document_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
saved_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
editor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
editor_name VARCHAR(200) NOT NULL,
|
||||
snapshot JSONB NOT NULL,
|
||||
changed_fields JSONB NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE INDEX ON document_versions (document_id, saved_at DESC);
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.raddatz.familienarchiv;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class FamilienarchivApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.MassImportService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(AdminController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class AdminControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean MassImportService massImportService;
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean DocumentVersionService documentVersionService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
@Test
|
||||
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillVersions_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void backfillVersions_returns200_withCount_whenAdmin() throws Exception {
|
||||
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
||||
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(1));
|
||||
}
|
||||
|
||||
// ─── POST /api/admin/backfill-file-hashes ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
||||
when(documentService.backfillFileHashes()).thenReturn(3);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(AnnotationController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class AnnotationControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean AnnotationService annotationService;
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final String ANNOTATION_JSON =
|
||||
"{\"pageNumber\":1,\"x\":0.1,\"y\":0.1,\"width\":0.2,\"height\":0.2,\"color\":\"#ff0000\"}";
|
||||
|
||||
// ─── GET /api/documents/{documentId}/annotations ──────────────────────────
|
||||
|
||||
@Test
|
||||
void listAnnotations_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void listAnnotations_returns200_whenAuthenticated() throws Exception {
|
||||
when(annotationService.listAnnotations(any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/annotations ─────────────────────────
|
||||
|
||||
@Test
|
||||
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.pageNumber").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void createAnnotation_returns409_whenOverlap() throws Exception {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/documents/{documentId}/annotations/{annotationId} ─────────
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CommentService;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(CommentController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class CommentControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean CommentService commentService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
|
||||
private static final UUID DOC_ID = UUID.randomUUID();
|
||||
private static final UUID ANN_ID = UUID.randomUUID();
|
||||
private static final UUID COMMENT_ID = UUID.randomUUID();
|
||||
|
||||
// ─── GET /api/documents/{documentId}/comments ─────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentComments_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentComments_returns200_whenAuthenticated() throws Exception {
|
||||
when(commentService.getCommentsForDocument(any())).thenReturn(List.of());
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/comments ────────────────────────────
|
||||
|
||||
@Test
|
||||
void postDocumentComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void postDocumentComment_returns403_whenMissingPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.content").value("Test comment"));
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
|
||||
|
||||
@Test
|
||||
void replyToComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||
.authorName("Anna").content("Test comment").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── PATCH /api/documents/{documentId}/comments/{commentId} ──────────────
|
||||
|
||||
@Test
|
||||
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void editComment_returns200_whenHasPermission() throws Exception {
|
||||
DocumentComment updated = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/documents/{documentId}/comments/{commentId} ─────────────
|
||||
|
||||
@Test
|
||||
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{documentId}/annotations/{annId}/comments ─────────
|
||||
|
||||
@Test
|
||||
void getAnnotationComments_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getAnnotationComments_returns200_whenAuthenticated() throws Exception {
|
||||
when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of());
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void postAnnotationComment_returns403_whenMissingPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void postAnnotationComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||
.authorName("Hans").content("Test comment").build();
|
||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToAnnotationComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.FileService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(DocumentController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class DocumentControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean DocumentVersionService documentVersionService;
|
||||
@MockitoBean FileService fileService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/documents/search ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void search_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns200_whenAuthenticated() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createDocument_returns200_whenHasWritePermission() throws Exception {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Test")
|
||||
.originalFilename("test.pdf")
|
||||
.build();
|
||||
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(multipart("/api/documents"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── PUT /api/documents/{id} ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void updateDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updateDocument_returns200_whenHasWritePermission() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Updated")
|
||||
.originalFilename("test.pdf")
|
||||
.build();
|
||||
when(documentService.updateDocument(any(), any(), any())).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + id)
|
||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getVersions_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getVersions_returns200_whenAuthenticated() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentVersionSummary summary = new DocumentVersionSummary(
|
||||
UUID.randomUUID(), LocalDateTime.now(), "Emma Müller", List.of("title"));
|
||||
when(documentVersionService.getSummaries(docId)).thenReturn(List.of(summary));
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + docId + "/versions"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].editorName").value("Emma Müller"));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions/{versionId} ────────────────────────
|
||||
|
||||
@Test
|
||||
void getVersion_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/versions/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getVersion_returns200_whenAuthenticated() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID versionId = UUID.randomUUID();
|
||||
DocumentVersion version = DocumentVersion.builder()
|
||||
.id(versionId).documentId(docId).savedAt(LocalDateTime.now())
|
||||
.editorName("Otto").snapshot("{\"title\":\"Brief\"}").changedFields("[]").build();
|
||||
when(documentVersionService.getVersion(docId, versionId)).thenReturn(version);
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + docId + "/versions/" + versionId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(PersonController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class PersonControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean PersonService personService;
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/persons/{id}/received-documents ─────────────────────────────
|
||||
|
||||
@Test
|
||||
void getReceivedDocuments_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/persons/{id}/received-documents", UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getReceivedDocuments_returns200_whenAuthenticated() throws Exception {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(documentService.getDocumentsByReceiver(personId)).thenReturn(Collections.emptyList());
|
||||
|
||||
mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.TagService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(TagController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class TagControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean TagService tagService;
|
||||
@MockitoBean DocumentService documentService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/tags ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchTags_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/tags"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void searchTags_returns200_whenAuthenticated() throws Exception {
|
||||
when(tagService.search(any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/tags"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── PUT /api/tags/{id} ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void updateTag_returns200_whenHasAdminTagPermission() throws Exception {
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
|
||||
when(tagService.update(any(), any())).thenReturn(tag);
|
||||
|
||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\": \"New\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/tags/{id} ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN_TAG")
|
||||
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.HttpStatus.CONFLICT;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AnnotationServiceTest {
|
||||
|
||||
@Mock AnnotationRepository annotationRepository;
|
||||
@InjectMocks AnnotationService annotationService;
|
||||
|
||||
// ─── createAnnotation ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createAnnotation_throwsConflict_whenAnnotationOverlapsExisting() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.3, 0.3, "#ff0000");
|
||||
|
||||
DocumentAnnotation existing = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||
.x(0.2).y(0.2).width(0.3).height(0.3).color("#00ff00").build();
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
|
||||
.thenReturn(List.of(existing));
|
||||
|
||||
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
|
||||
|
||||
verify(annotationRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAnnotation_savesAndReturns_whenNoOverlap() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
|
||||
when(annotationRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(annotationRepository).save(any());
|
||||
}
|
||||
|
||||
// ─── deleteAnnotation ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_throwsNotFound_whenMissing() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, UUID.randomUUID()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_throwsForbidden_whenNotOwner() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID otherId = UUID.randomUUID();
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||
.id(annotId).documentId(docId).createdBy(ownerId).build();
|
||||
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
|
||||
.thenReturn(Optional.of(annotation));
|
||||
|
||||
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, otherId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||
|
||||
verify(annotationRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_succeeds_whenOwner() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||
.id(annotId).documentId(docId).createdBy(ownerId).build();
|
||||
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
|
||||
.thenReturn(Optional.of(annotation));
|
||||
|
||||
annotationService.deleteAnnotation(docId, annotId, ownerId);
|
||||
|
||||
verify(annotationRepository).delete(annotation);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAnnotation_setsFileHash_whenProvided() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||
String fileHash = "abc123";
|
||||
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
|
||||
|
||||
assertThat(result.getFileHash()).isEqualTo(fileHash);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAnnotation_setsNullFileHash_whenNoneProvided() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
||||
|
||||
assertThat(result.getFileHash()).isNull();
|
||||
}
|
||||
|
||||
// ─── listAnnotations ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void listAnnotations_returnsAllForDocument() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentAnnotation a = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).build();
|
||||
when(annotationRepository.findByDocumentId(docId)).thenReturn(List.of(a));
|
||||
|
||||
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
|
||||
}
|
||||
|
||||
// ─── backfillAnnotationFileHashForDocument ────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfillAnnotationFileHashForDocument_setsHashOnAnnotationsWithNullHash() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
String hash = "abc123";
|
||||
DocumentAnnotation a = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).build();
|
||||
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of(a));
|
||||
|
||||
annotationService.backfillAnnotationFileHashForDocument(docId, hash);
|
||||
|
||||
assertThat(a.getFileHash()).isEqualTo(hash);
|
||||
verify(annotationRepository).save(a);
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillAnnotationFileHashForDocument_doesNothingWhenNoAnnotations() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of());
|
||||
|
||||
annotationService.backfillAnnotationFileHashForDocument(docId, "hash");
|
||||
|
||||
verify(annotationRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CommentServiceTest {
|
||||
|
||||
@Mock CommentRepository commentRepository;
|
||||
@InjectMocks CommentService commentService;
|
||||
|
||||
// ─── postComment ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void postComment_capturesAuthorNameAtWriteTime() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder()
|
||||
.id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("Müller").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_fallsBackToUsername_whenNamesAreBlank() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans42").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
||||
}
|
||||
|
||||
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void replyToComment_throwsNotFound_whenTargetCommentMissing() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||
|
||||
verify(commentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replyToComment_resolvesToRootParent_whenReplyingToAReply() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID rootId = UUID.randomUUID();
|
||||
UUID replyId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||
|
||||
DocumentComment root = DocumentComment.builder()
|
||||
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||
DocumentComment existingReply = DocumentComment.builder()
|
||||
.id(replyId).documentId(docId).parentId(rootId).content("Reply1").authorName("Anna").build();
|
||||
|
||||
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
|
||||
|
||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replyToComment_usesDirectComment_whenReplyingToTopLevel() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID rootId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||
|
||||
DocumentComment root = DocumentComment.builder()
|
||||
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||
|
||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author);
|
||||
|
||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||
}
|
||||
|
||||
// ─── editComment ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void editComment_throwsForbidden_whenNotAuthor() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
|
||||
assertThatThrownBy(() -> commentService.editComment(docId, commentId, "Changed", other))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||
|
||||
verify(commentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editComment_updatesContent_whenAuthor() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID authorId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(authorId).username("hans").build();
|
||||
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(authorId)
|
||||
.content("Original").authorName("Hans").createdAt(created).build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
|
||||
|
||||
assertThat(result.getContent()).isEqualTo("Updated");
|
||||
assertThat(result.getCreatedAt()).isEqualTo(created);
|
||||
}
|
||||
|
||||
// ─── deleteComment ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteComment_throwsForbidden_whenNotAuthorAndNotAdmin() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
|
||||
assertThatThrownBy(() -> commentService.deleteComment(docId, commentId, other))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||
|
||||
verify(commentRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteComment_succeeds_whenAuthor() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID authorId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(authorId).username("hans").build();
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
|
||||
commentService.deleteComment(docId, commentId, author);
|
||||
|
||||
verify(commentRepository).delete(comment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteComment_succeeds_whenAdmin() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
AppUser admin = buildAdmin();
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
|
||||
commentService.deleteComment(docId, commentId, admin);
|
||||
|
||||
verify(commentRepository).delete(comment);
|
||||
}
|
||||
|
||||
// ─── getCommentsForDocument ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getCommentsForDocument_returnsRootsWithRepliesAttached() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID rootId = UUID.randomUUID();
|
||||
|
||||
DocumentComment root = DocumentComment.builder()
|
||||
.id(rootId).documentId(docId).authorName("Hans").content("Root").build();
|
||||
DocumentComment reply = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build();
|
||||
|
||||
when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId))
|
||||
.thenReturn(List.of(root));
|
||||
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply));
|
||||
|
||||
List<DocumentComment> result = commentService.getCommentsForDocument(docId);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private AppUser buildAdmin() {
|
||||
return AppUser.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.username("admin")
|
||||
.groups(Set.of(UserGroup.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.name("admins")
|
||||
.permissions(Set.of("ADMIN"))
|
||||
.build()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentServiceTest {
|
||||
|
||||
@Mock DocumentRepository documentRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock FileService fileService;
|
||||
@Mock TagService tagService;
|
||||
@Mock DocumentVersionService documentVersionService;
|
||||
@Mock AnnotationService annotationService;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── getDocumentById ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.getDocumentById(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDocumentById_returnsDocument_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("Test").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
|
||||
}
|
||||
|
||||
// ─── updateDocument ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateDocument_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.updateDocument(id, new DocumentUpdateDTO(), null))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteTagCascading_removesTagFromAllDocumentsAndDeletesTag() {
|
||||
UUID tagId = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(tagId).name("Familie").build();
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.tags(new HashSet<>(Set.of(tag)))
|
||||
.build();
|
||||
|
||||
when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
documentService.deleteTagCascading(tagId);
|
||||
|
||||
assertThat(doc.getTags()).isEmpty();
|
||||
verify(tagService).delete(tagId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteTagCascading_worksWhenNoDocumentsHaveTag() {
|
||||
UUID tagId = UUID.randomUUID();
|
||||
when(documentRepository.findByTags_Id(tagId)).thenReturn(List.of());
|
||||
|
||||
documentService.deleteTagCascading(tagId);
|
||||
|
||||
verify(documentRepository, never()).save(any());
|
||||
verify(tagService).delete(tagId);
|
||||
}
|
||||
|
||||
// ─── createPlaceholder ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createPlaceholder_returnsExisting_whenAlreadyExists() {
|
||||
String filename = "scan001.pdf";
|
||||
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build();
|
||||
when(documentRepository.existsByOriginalFilename(filename)).thenReturn(true);
|
||||
when(documentRepository.findByOriginalFilename(filename)).thenReturn(Optional.of(existing));
|
||||
|
||||
Document result = documentService.createPlaceholder(filename);
|
||||
|
||||
assertThat(result).isEqualTo(existing);
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPlaceholder_createsNew_whenNotExists() {
|
||||
String filename = "scan002.pdf";
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename(filename).build();
|
||||
when(documentRepository.existsByOriginalFilename(filename)).thenReturn(false);
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Document result = documentService.createPlaceholder(filename);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(documentRepository).save(any());
|
||||
}
|
||||
|
||||
// ─── getDocumentsByReceiver ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentsByReceiver_returnsDocumentsWherePersonIsReceiver() {
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
|
||||
when(documentRepository.findByReceiversId(receiverId)).thenReturn(List.of(doc));
|
||||
|
||||
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
||||
}
|
||||
|
||||
// ─── file hash propagation ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Doc");
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
|
||||
|
||||
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(savedDoc);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||
|
||||
documentService.createDocument(dto, file);
|
||||
|
||||
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Alt").originalFilename("old.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
org.springframework.mock.web.MockMultipartFile newFile =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
|
||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
|
||||
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
|
||||
|
||||
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
|
||||
}
|
||||
|
||||
// ─── versioning ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createDocument_recordsVersionAfterSave() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Neuer Brief");
|
||||
|
||||
Document saved = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Neuer Brief")
|
||||
.originalFilename("Neuer Brief").status(DocumentStatus.PLACEHOLDER)
|
||||
.build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verify(documentVersionService, atLeastOnce()).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_recordsVersionAfterSave() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Alt").originalFilename("alt.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
documentService.updateDocument(id, new DocumentUpdateDTO(), null);
|
||||
|
||||
verify(documentVersionService).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_skipsDocumentsWithNoFilePath() throws Exception {
|
||||
Document noFile = Document.builder().id(UUID.randomUUID()).build();
|
||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of());
|
||||
|
||||
int count = documentService.backfillFileHashes();
|
||||
|
||||
assertThat(count).isZero();
|
||||
verify(fileService, never()).downloadFileBytes(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_computesHashAndSavesDocument() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
||||
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.backfillFileHashes();
|
||||
|
||||
assertThat(doc.getFileHash()).isNotNull().hasSize(64);
|
||||
verify(documentRepository).save(doc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_propagatesHashToAnnotations() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
||||
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.backfillFileHashes();
|
||||
|
||||
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
Document doc1 = Document.builder().id(id1).filePath("documents/a.pdf").build();
|
||||
Document doc2 = Document.builder().id(id2).filePath("documents/b.pdf").build();
|
||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc1, doc2));
|
||||
when(fileService.downloadFileBytes(any())).thenReturn(new byte[]{1});
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
int count = documentService.backfillFileHashes();
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentVersionRepository;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentVersionServiceTest {
|
||||
|
||||
@Mock DocumentVersionRepository versionRepository;
|
||||
@Mock UserService userService;
|
||||
|
||||
private DocumentVersionService versionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
versionService = new DocumentVersionService(versionRepository, userService, new ObjectMapper());
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ─── recordVersion — editor name ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void recordVersion_usesFirstAndLastName_whenBothPresent() {
|
||||
authenticateAs("emma");
|
||||
when(userService.findByUsername("emma")).thenReturn(
|
||||
AppUser.builder().id(UUID.randomUUID()).username("emma")
|
||||
.firstName("Emma").lastName("Müller").build());
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.recordVersion(minimalDocument());
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getEditorName()).isEqualTo("Emma Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_usesUsername_whenNamesAreBlank() {
|
||||
authenticateAs("otto99");
|
||||
when(userService.findByUsername("otto99")).thenReturn(
|
||||
AppUser.builder().id(UUID.randomUUID()).username("otto99")
|
||||
.firstName(null).lastName(null).build());
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.recordVersion(minimalDocument());
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getEditorName()).isEqualTo("otto99");
|
||||
}
|
||||
|
||||
// ─── recordVersion — snapshot ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void recordVersion_savesSnapshotContainingTitle() {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Wichtiger Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.build();
|
||||
|
||||
versionService.recordVersion(doc);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSnapshot()).contains("Wichtiger Brief");
|
||||
assertThat(captor.getValue().getDocumentId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
// ─── recordVersion — changedFields ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void recordVersion_changedFieldsIsEmpty_forFirstVersion() {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.recordVersion(minimalDocument());
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_includesTitleInChangedFields_whenTitleChanged() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
Document oldDoc = Document.builder().id(UUID.randomUUID()).title("Alt").build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.documentId(oldDoc.getId())
|
||||
.snapshot(oldSnapshot)
|
||||
.changedFields("[]")
|
||||
.savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1")
|
||||
.build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(oldDoc.getId()))
|
||||
.thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document updated = Document.builder().id(oldDoc.getId()).title("Neu").build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("title");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_doesNotIncludeUnchangedFields_inChangedFields() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document oldDoc = Document.builder().id(docId).title("Same").location("Berlin").build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1").build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document updated = Document.builder().id(docId).title("Same").location("Hamburg").build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("location");
|
||||
assertThat(captor.getValue().getChangedFields()).doesNotContain("title");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_tracksSenderChange() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
UUID docId = UUID.randomUUID();
|
||||
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
||||
Document oldDoc = Document.builder().id(docId).title("T").sender(oldSender).build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1").build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Person newSender = Person.builder().id(UUID.randomUUID()).firstName("C").lastName("D").build();
|
||||
Document updated = Document.builder().id(docId).title("T").sender(newSender).build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("sender");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_tracksReceiverChange() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
UUID docId = UUID.randomUUID();
|
||||
Person receiver1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
||||
Document oldDoc = Document.builder().id(docId).title("T").receivers(Set.of(receiver1)).build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1").build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document updated = Document.builder().id(docId).title("T").receivers(Set.of()).build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("receivers");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordVersion_tracksTagChange() throws Exception {
|
||||
authenticateAs("user1");
|
||||
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
UUID docId = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
Document oldDoc = Document.builder().id(docId).title("T").tags(Set.of(tag)).build();
|
||||
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||
|
||||
DocumentVersion previous = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||
.editorName("user1").build();
|
||||
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Document updated = Document.builder().id(docId).title("T").tags(Set.of()).build();
|
||||
versionService.recordVersion(updated);
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).contains("tags");
|
||||
}
|
||||
|
||||
// ─── getSummaries ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getSummaries_returnsListWithParsedChangedFields() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentVersion v = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId)
|
||||
.savedAt(LocalDateTime.now()).editorName("Emma Müller")
|
||||
.snapshot("{}").changedFields("[\"title\",\"location\"]")
|
||||
.build();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(v));
|
||||
|
||||
List<DocumentVersionSummary> summaries = versionService.getSummaries(docId);
|
||||
|
||||
assertThat(summaries).hasSize(1);
|
||||
assertThat(summaries.get(0).editorName()).isEqualTo("Emma Müller");
|
||||
assertThat(summaries.get(0).changedFields()).containsExactlyInAnyOrder("title", "location");
|
||||
assertThat(summaries.get(0).id()).isEqualTo(v.getId());
|
||||
}
|
||||
|
||||
// ─── getVersion ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getVersion_returnsVersion_whenFound() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID versionId = UUID.randomUUID();
|
||||
DocumentVersion v = DocumentVersion.builder()
|
||||
.id(versionId).documentId(docId).snapshot("{}")
|
||||
.changedFields("[]").editorName("x").savedAt(LocalDateTime.now()).build();
|
||||
when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.of(v));
|
||||
|
||||
assertThat(versionService.getVersion(docId, versionId)).isEqualTo(v);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVersion_throwsNotFound_whenVersionBelongsToOtherDocument() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID versionId = UUID.randomUUID();
|
||||
when(versionRepository.findByIdAndDocumentId(versionId, docId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> versionService.getVersion(docId, versionId))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// ─── backfillMissingVersions ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfill_createsVersion_withEditorNameDatenimport() {
|
||||
Document doc = minimalDocument();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.backfillMissingVersions(List.of(doc));
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getEditorName()).isEqualTo("Datenimport");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_usesDocumentCreatedAt_asSavedAt() {
|
||||
LocalDateTime createdAt = LocalDateTime.of(2020, 3, 15, 10, 0);
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID()).title("T").createdAt(createdAt).build();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.backfillMissingVersions(List.of(doc));
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSavedAt()).isEqualTo(createdAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_setsChangedFieldsEmpty() {
|
||||
Document doc = minimalDocument();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
versionService.backfillMissingVersions(List.of(doc));
|
||||
|
||||
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||
verify(versionRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_skipsDocuments_thatAlreadyHaveVersions() {
|
||||
Document doc = minimalDocument();
|
||||
DocumentVersion existing = DocumentVersion.builder()
|
||||
.id(UUID.randomUUID()).documentId(doc.getId()).snapshot("{}")
|
||||
.changedFields("[]").editorName("user").savedAt(LocalDateTime.now()).build();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of(existing));
|
||||
|
||||
int count = versionService.backfillMissingVersions(List.of(doc));
|
||||
|
||||
verify(versionRepository, never()).save(any());
|
||||
assertThat(count).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_returnsCountOfCreatedVersions() {
|
||||
Document d1 = minimalDocument();
|
||||
Document d2 = minimalDocument();
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d1.getId())).thenReturn(List.of());
|
||||
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d2.getId())).thenReturn(List.of());
|
||||
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
int count = versionService.backfillMissingVersions(List.of(d1, d2));
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void authenticateAs(String username) {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(username, null, List.of()));
|
||||
}
|
||||
|
||||
private AppUser stubUser(String username) {
|
||||
return AppUser.builder().id(UUID.randomUUID()).username(username)
|
||||
.firstName(null).lastName(null).build();
|
||||
}
|
||||
|
||||
private Document minimalDocument() {
|
||||
return Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Test")
|
||||
.originalFilename("test.pdf")
|
||||
.documentDate(LocalDate.of(1940, 5, 1))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FileServiceTest {
|
||||
|
||||
private S3Client s3Client;
|
||||
private FileService fileService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
s3Client = mock(S3Client.class);
|
||||
fileService = new FileService(s3Client, "test-bucket");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFile_returnsS3Key() throws IOException {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
|
||||
|
||||
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
|
||||
|
||||
assertThat(result.s3Key()).startsWith("documents/");
|
||||
assertThat(result.s3Key()).endsWith("_test.pdf");
|
||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
|
||||
byte[] content = "hello pdf content".getBytes();
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "doc.pdf", "application/pdf", content);
|
||||
|
||||
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
|
||||
|
||||
// Compute expected hash independently
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes = digest.digest(content);
|
||||
StringBuilder expected = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
expected.append(String.format("%02x", b));
|
||||
}
|
||||
|
||||
assertThat(result.fileHash()).isEqualTo(expected.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
|
||||
MockMultipartFile file1 = new MockMultipartFile(
|
||||
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
|
||||
MockMultipartFile file2 = new MockMultipartFile(
|
||||
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
|
||||
|
||||
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
|
||||
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
|
||||
|
||||
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFile_sameContents_produceSameHash() throws IOException {
|
||||
byte[] content = new byte[]{10, 20, 30};
|
||||
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
|
||||
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
|
||||
|
||||
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
|
||||
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
|
||||
|
||||
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PasswordResetServiceTest {
|
||||
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock PasswordResetTokenRepository tokenRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@Mock JavaMailSender mailSender;
|
||||
@InjectMocks PasswordResetService service;
|
||||
|
||||
private AppUser makeUser(String email) {
|
||||
return AppUser.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.username("testuser")
|
||||
.email(email)
|
||||
.password("hashed")
|
||||
.build();
|
||||
}
|
||||
|
||||
// ─── requestReset ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void requestReset_savesTokenForKnownEmail() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||
|
||||
service.requestReset("user@example.com", "http://localhost:3000");
|
||||
|
||||
verify(tokenRepository).save(argThat(t ->
|
||||
t.getUser().equals(user)
|
||||
&& t.getToken().length() == 64
|
||||
&& !t.isUsed()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestReset_doesNothingForUnknownEmail() {
|
||||
when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
service.requestReset("ghost@example.com", "http://localhost:3000");
|
||||
|
||||
verify(tokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// ─── resetPassword ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void resetPassword_updatesPasswordForValidToken() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
PasswordResetToken token = PasswordResetToken.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.token("validtoken123")
|
||||
.user(user)
|
||||
.expiresAt(LocalDateTime.now().plusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
|
||||
when(passwordEncoder.encode("newpass")).thenReturn("hashed-newpass");
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("validtoken123");
|
||||
req.setNewPassword("newpass");
|
||||
service.resetPassword(req);
|
||||
|
||||
verify(passwordEncoder).encode("newpass");
|
||||
verify(userRepository).save(argThat(u -> u.getPassword().equals("hashed-newpass")));
|
||||
assertThat(token.isUsed()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPassword_throwsForExpiredToken() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
PasswordResetToken token = PasswordResetToken.builder()
|
||||
.token("expiredtoken")
|
||||
.user(user)
|
||||
.expiresAt(LocalDateTime.now().minusMinutes(1))
|
||||
.used(false)
|
||||
.build();
|
||||
when(tokenRepository.findByToken("expiredtoken")).thenReturn(Optional.of(token));
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("expiredtoken");
|
||||
req.setNewPassword("newpass");
|
||||
|
||||
assertThatThrownBy(() -> service.resetPassword(req))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPassword_throwsForUnknownToken() {
|
||||
when(tokenRepository.findByToken("nosuchtoken")).thenReturn(Optional.empty());
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("nosuchtoken");
|
||||
req.setNewPassword("newpass");
|
||||
|
||||
assertThatThrownBy(() -> service.resetPassword(req))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PersonServiceTest {
|
||||
|
||||
@Mock PersonRepository personRepository;
|
||||
@InjectMocks PersonService personService;
|
||||
|
||||
// ─── getById ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> personService.getById(id))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returnsPerson_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
|
||||
assertThat(personService.getById(id)).isEqualTo(person);
|
||||
}
|
||||
|
||||
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
|
||||
String alias = "Walter de Gruyter";
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(existing);
|
||||
verify(personRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
|
||||
String alias = "Clara Cram";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(personRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_trimsInput() {
|
||||
String alias = " Clara Cram ";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
|
||||
when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved));
|
||||
|
||||
personService.findOrCreateByAlias(alias);
|
||||
|
||||
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
|
||||
}
|
||||
|
||||
// ─── updatePerson (notes) ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsNotes() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes("Some notes here.");
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getNotes()).isEqualTo("Some notes here.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_clearsNotes_whenBlank() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").notes("old notes").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes(" ");
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getNotes()).isNull();
|
||||
}
|
||||
|
||||
// ─── updatePerson (birth/death years) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsBirthAndDeathYear() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1890);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1965);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_allowsSameYear() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1900);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1900);
|
||||
}
|
||||
|
||||
// ─── findCorrespondents ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findCorrespondents_delegatesToRepository_withoutFilter() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build());
|
||||
when(personRepository.findCorrespondents(personId)).thenReturn(expected);
|
||||
|
||||
assertThat(personService.findCorrespondents(personId, null)).isEqualTo(expected);
|
||||
verify(personRepository).findCorrespondents(personId);
|
||||
verify(personRepository, never()).findCorrespondentsWithFilter(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findCorrespondents_delegatesToRepository_withFilter() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build());
|
||||
when(personRepository.findCorrespondentsWithFilter(personId, "Anna")).thenReturn(expected);
|
||||
|
||||
assertThat(personService.findCorrespondents(personId, "Anna")).isEqualTo(expected);
|
||||
verify(personRepository).findCorrespondentsWithFilter(personId, "Anna");
|
||||
verify(personRepository, never()).findCorrespondents(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findCorrespondents_delegatesToRepository_withBlankFilter() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(personRepository.findCorrespondents(personId)).thenReturn(List.of());
|
||||
|
||||
personService.findCorrespondents(personId, " ");
|
||||
|
||||
verify(personRepository).findCorrespondents(personId);
|
||||
verify(personRepository, never()).findCorrespondentsWithFilter(any(), any());
|
||||
}
|
||||
|
||||
// ─── mergePersons ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void mergePersons_throwsBadRequest_whenSourceEqualsTarget() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
assertThatThrownBy(() -> personService.mergePersons(id, id))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergePersons_throwsNotFound_whenSourceMissing() {
|
||||
UUID sourceId = UUID.randomUUID();
|
||||
UUID targetId = UUID.randomUUID();
|
||||
when(personRepository.findById(sourceId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergePersons_throwsNotFound_whenTargetMissing() {
|
||||
UUID sourceId = UUID.randomUUID();
|
||||
UUID targetId = UUID.randomUUID();
|
||||
Person source = Person.builder().id(sourceId).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(sourceId)).thenReturn(Optional.of(source));
|
||||
when(personRepository.findById(targetId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergePersons_reassignsDocumentsAndDeletesSource() {
|
||||
UUID sourceId = UUID.randomUUID();
|
||||
UUID targetId = UUID.randomUUID();
|
||||
Person source = Person.builder().id(sourceId).firstName("Anna").lastName("Alt").build();
|
||||
Person target = Person.builder().id(targetId).firstName("Anna").lastName("Neu").build();
|
||||
when(personRepository.findById(sourceId)).thenReturn(Optional.of(source));
|
||||
when(personRepository.findById(targetId)).thenReturn(Optional.of(target));
|
||||
|
||||
personService.mergePersons(sourceId, targetId);
|
||||
|
||||
verify(personRepository).reassignSender(sourceId, targetId);
|
||||
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
|
||||
verify(personRepository).deleteReceiverReferences(sourceId);
|
||||
verify(personRepository).deleteById(sourceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.TagRepository;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TagServiceTest {
|
||||
|
||||
@Mock TagRepository tagRepository;
|
||||
@InjectMocks TagService tagService;
|
||||
|
||||
// ─── getById ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> tagService.getById(id))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returnsTag_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(id).name("Familie").build();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
|
||||
|
||||
assertThat(tagService.getById(id)).isEqualTo(tag);
|
||||
}
|
||||
|
||||
// ─── findOrCreate ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findOrCreate_returnsExisting_whenNameFound() {
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Familie")).thenReturn(Optional.of(existing));
|
||||
|
||||
Tag result = tagService.findOrCreate("Familie");
|
||||
|
||||
assertThat(result).isEqualTo(existing);
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_createsNew_whenNameNotFound() {
|
||||
Tag saved = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Krieg")).thenReturn(Optional.empty());
|
||||
when(tagRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Tag result = tagService.findOrCreate("Krieg");
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(tagRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_trimsWhitespaceBeforeLookup() {
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Urlaub").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Urlaub")).thenReturn(Optional.of(existing));
|
||||
|
||||
tagService.findOrCreate(" Urlaub ");
|
||||
|
||||
verify(tagRepository).findByNameIgnoreCase("Urlaub");
|
||||
}
|
||||
|
||||
// ─── update ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void update_savesNewName() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(id).name("Old").build();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
|
||||
when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Tag result = tagService.update(id, "New");
|
||||
|
||||
assertThat(result.getName()).isEqualTo("New");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_throwsNotFound_whenTagMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> tagService.update(id, "New"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
|
||||
// ─── delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void delete_callsRepositoryDelete() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag tag = Tag.builder().id(id).name("ToDelete").build();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.of(tag));
|
||||
|
||||
tagService.delete(id);
|
||||
|
||||
verify(tagRepository).delete(tag);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_throwsNotFound_whenTagMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(tagRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> tagService.delete(id))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.UserGroupRepository;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock UserGroupRepository groupRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@InjectMocks UserService userService;
|
||||
|
||||
// ─── findByUsername ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findByUsername_throwsNotFound_whenMissing() {
|
||||
when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.findByUsername("ghost"))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByUsername_returnsUser_whenFound() {
|
||||
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("admin").build();
|
||||
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
|
||||
|
||||
assertThat(userService.findByUsername("admin")).isEqualTo(user);
|
||||
}
|
||||
|
||||
// ─── deleteUser ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteUser_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.deleteUser(id))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUser_deletesUser_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("gast").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
|
||||
userService.deleteUser(id);
|
||||
|
||||
verify(userRepository).delete(user);
|
||||
}
|
||||
|
||||
// ─── createUserOrUpdate ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createUserOrUpdate_createsNewUser_whenNotExists() {
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setUsername("newuser");
|
||||
req.setEmail("new@example.com");
|
||||
req.setInitialPassword("secret");
|
||||
req.setGroupIds(List.of());
|
||||
|
||||
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
|
||||
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
AppUser result = userService.createUserOrUpdate(req);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(userRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserOrUpdate_updatesExistingUser_whenFound() {
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setUsername("existing");
|
||||
req.setEmail("existing@example.com");
|
||||
req.setInitialPassword("newpass");
|
||||
req.setGroupIds(List.of());
|
||||
|
||||
AppUser existing = AppUser.builder().id(UUID.randomUUID()).username("existing").build();
|
||||
when(userRepository.findByUsername("existing")).thenReturn(Optional.of(existing));
|
||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
userService.createUserOrUpdate(req);
|
||||
|
||||
// save called once with the updated existing user (no new user created)
|
||||
verify(userRepository, times(1)).save(existing);
|
||||
}
|
||||
|
||||
// ─── getById ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.getById(id))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returnsUser_whenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
|
||||
assertThat(userService.getById(id)).isEqualTo(user);
|
||||
}
|
||||
|
||||
// ─── updateProfile ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateProfile_updatesFields() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||
dto.setFirstName("Max"); dto.setLastName("Müller"); dto.setEmail("max@example.com");
|
||||
AppUser result = userService.updateProfile(id, dto);
|
||||
|
||||
assertThat(result.getFirstName()).isEqualTo("Max");
|
||||
assertThat(result.getLastName()).isEqualTo("Müller");
|
||||
assertThat(result.getEmail()).isEqualTo("max@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfile_throwsConflict_whenEmailTakenByAnotherUser() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID otherId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||
AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
|
||||
|
||||
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||
dto.setEmail("taken@example.com");
|
||||
|
||||
assertThatThrownBy(() -> userService.updateProfile(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("E-Mail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfile_allowsSameEmailForSameUser() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").email("max@example.com").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||
dto.setEmail("max@example.com");
|
||||
dto.setFirstName("Max");
|
||||
|
||||
assertThat(userService.updateProfile(id, dto).getEmail()).isEqualTo("max@example.com");
|
||||
}
|
||||
|
||||
// ─── changePassword ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void changePassword_throwsBadRequest_whenCurrentPasswordWrong() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("wrong", "hashed")).thenReturn(false);
|
||||
|
||||
ChangePasswordDTO dto = new ChangePasswordDTO();
|
||||
dto.setCurrentPassword("wrong"); dto.setNewPassword("newpass");
|
||||
|
||||
assertThatThrownBy(() -> userService.changePassword(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("Passwort");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changePassword_updatesHash_whenCurrentPasswordCorrect() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("correct", "hashed")).thenReturn(true);
|
||||
when(passwordEncoder.encode("newpass")).thenReturn("newHash");
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
ChangePasswordDTO dto = new ChangePasswordDTO();
|
||||
dto.setCurrentPassword("correct"); dto.setNewPassword("newpass");
|
||||
userService.changePassword(id, dto);
|
||||
|
||||
verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword())));
|
||||
}
|
||||
|
||||
// ─── getGroupById ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getGroupById_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(groupRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.getGroupById(id))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
}
|
||||
18
docker-compose.ci.yml
Normal file
18
docker-compose.ci.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
# CI override — replaces host bind mounts with ephemeral named volumes.
|
||||
# Host port bindings are handled via PORT_DB/PORT_MINIO_API env vars in ci.yml
|
||||
# (set to non-standard ports to avoid conflicts with system services on the runner).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d db minio create-buckets
|
||||
services:
|
||||
db:
|
||||
volumes:
|
||||
- ci_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
minio:
|
||||
volumes:
|
||||
- ci_minio_data:/data
|
||||
|
||||
volumes:
|
||||
ci_postgres_data:
|
||||
ci_minio_data:
|
||||
@@ -58,55 +58,100 @@ services:
|
||||
networks:
|
||||
- archive-net
|
||||
|
||||
# --- Mail catcher: Mailpit (dev only) ---
|
||||
# Catches all outgoing emails and displays them in a web UI.
|
||||
# Access the inbox at http://localhost:${PORT_MAILPIT_UI} after starting the stack.
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
container_name: archive-mailpit
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT_MAILPIT_UI:-8025}:8025" # Web UI
|
||||
- "${PORT_MAILPIT_SMTP:-1025}:1025" # SMTP
|
||||
networks:
|
||||
- archive-net
|
||||
|
||||
# --- Backend: Spring Boot ---
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: archive-backend
|
||||
command: sleep infinity
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- .:/workspaces/familienarchiv:cached
|
||||
- ./import-data:/import # Mappt den lokalen Ordner "import-data" auf "/import" im Container
|
||||
- ./backend:/app
|
||||
- ./import:/import
|
||||
- maven_cache:/root/.m2
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# MinIO Konfiguration für Spring Boot (S3)
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER}
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
|
||||
S3_REGION: us-east-1 # MinIO Standard
|
||||
S3_REGION: us-east-1
|
||||
SPRING_PROFILES_ACTIVE: dev,e2e
|
||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
|
||||
# Defaults to the local Mailpit catcher — override in .env for production SMTP
|
||||
MAIL_HOST: ${MAIL_HOST:-mailpit}
|
||||
MAIL_PORT: ${MAIL_PORT:-1025}
|
||||
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||
APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@familienarchiv.local}
|
||||
# Mailpit needs no auth or STARTTLS; production SMTP overrides these via .env
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-false}
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
||||
ports:
|
||||
- "${PORT_BACKEND}:8080"
|
||||
networks:
|
||||
- archive-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
|
||||
# --- Frontend: SvelteKit ---
|
||||
# Auch hier brauchen wir erst das Dockerfile im frontend Ordner.
|
||||
# frontend:
|
||||
# build: ./frontend
|
||||
# container_name: archive-frontend
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - backend
|
||||
# environment:
|
||||
# # SvelteKit SSR braucht die interne Docker-URL zum Backend
|
||||
# API_BASE_URL: http://backend:8080
|
||||
# # Der Browser braucht die öffentliche URL (falls Client-Side Fetching genutzt wird)
|
||||
# PUBLIC_API_BASE_URL: http://localhost:${PORT_BACKEND}
|
||||
# ports:
|
||||
# - "${PORT_FRONTEND}:3000"
|
||||
# networks:
|
||||
# - archive-net
|
||||
# --- Frontend: SvelteKit (Dev Server) ---
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: archive-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
# Keep container's node_modules separate from host to avoid OS binary conflicts
|
||||
- frontend_node_modules:/app/node_modules
|
||||
environment:
|
||||
# SSR calls (server-side) use the internal Docker network
|
||||
API_INTERNAL_URL: http://backend:8080
|
||||
# Vite dev proxy forwards /api from browser to the backend container
|
||||
API_PROXY_TARGET: http://backend:8080
|
||||
ports:
|
||||
- "${PORT_FRONTEND}:5173"
|
||||
networks:
|
||||
- archive-net
|
||||
|
||||
networks:
|
||||
archive-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
frontend_node_modules:
|
||||
maven_cache:
|
||||
|
||||
389
docs/STYLEGUIDE.md
Normal file
389
docs/STYLEGUIDE.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# Familienarchiv — Design Styleguide
|
||||
|
||||
This document defines the visual language for the Familienarchiv frontend. All UI work should follow these conventions to stay consistent with the De Gruyter Brill corporate identity.
|
||||
|
||||
---
|
||||
|
||||
## Brand Identity
|
||||
|
||||
The design is based on the **De Gruyter Brill** brand identity (unveiled at Frankfurt Book Fair 2024). Key characteristics:
|
||||
|
||||
- Clean white backgrounds, high contrast
|
||||
- Strong typographic hierarchy (uppercase labels, serif body text)
|
||||
- Academic publisher aesthetic: authoritative, clear, uncluttered
|
||||
|
||||
---
|
||||
|
||||
## Colors
|
||||
|
||||
Defined in `src/routes/layout.css` as `@theme` variables. All generate Tailwind utilities automatically (`bg-*`, `text-*`, `border-*`).
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| `brand-navy` | `#012851` | Primary text, headings, buttons, active states — Prussian Blue |
|
||||
| `brand-mint` | `#A1DCD8` | Accent color, icon tints, hover underlines — Aqua Island |
|
||||
| `brand-purple` | `#B4B9FF` | Logo, nav active state highlight, top accent strip — Melrose |
|
||||
| `brand-sand` | `#F0EFE9` | Subtle card backgrounds, borders, hover backgrounds — paper tone |
|
||||
| `brand-white` | `#FFFFFF` | Page background, card surfaces |
|
||||
| `brand-dark` | `#0D0D0D` | Near-black text when maximum contrast is needed |
|
||||
|
||||
### Color usage rules
|
||||
|
||||
- **Never** use raw hex values in components — always use token utilities.
|
||||
- Page and card backgrounds are **white**. Use `bg-brand-sand` only for subtle inset areas (e.g. `bg-brand-sand/30`).
|
||||
- `brand-navy` is the workhorse: headings, body text, borders, primary buttons.
|
||||
- `brand-mint` is an accent only — never use it as primary text color on white (contrast too low).
|
||||
- `brand-purple` is reserved for the logo and the single top accent strip in the header.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Fonts
|
||||
|
||||
| Role | Font | Tailwind | Notes |
|
||||
|---|---|---|---|
|
||||
| Body / Serif | **Tinos** (Times substitute) | `font-serif` | Loaded from Google Fonts. Used for document titles, names, body copy, dates. Matches DGB's use of Times. |
|
||||
| UI / Sans | **Montserrat** (Gotham substitute) | `font-sans` | Loaded from Google Fonts. Used for labels, navigation, buttons, metadata, form elements. Matches DGB's use of Gotham. |
|
||||
|
||||
### Type scale and usage
|
||||
|
||||
| Element | Classes | Example |
|
||||
|---|---|---|
|
||||
| Page title | `font-serif text-3xl text-brand-navy` | `<h1>` |
|
||||
| Card section heading | `font-sans text-xs font-bold uppercase tracking-widest text-gray-400` | Section labels |
|
||||
| Document / item title | `font-serif text-xl font-medium text-brand-navy` | List items |
|
||||
| Metadata / label | `font-sans text-xs font-bold uppercase tracking-widest text-gray-500` | Field labels |
|
||||
| Body text | `font-serif text-sm text-brand-navy` | Descriptions, summaries |
|
||||
| Navigation | `font-sans text-xs font-bold uppercase tracking-widest` | Nav links |
|
||||
|
||||
### Rules
|
||||
|
||||
- **Labels are always uppercase + tracked**: `text-xs font-bold uppercase tracking-widest`
|
||||
- **Headings** use `font-sans` (Montserrat), set in CSS globally.
|
||||
- **Content** (document titles, person names, summaries) uses `font-serif` (Tinos).
|
||||
- Never use `font-serif` for UI chrome (buttons, labels, nav).
|
||||
|
||||
---
|
||||
|
||||
## Icons
|
||||
|
||||
### Library
|
||||
|
||||
686 SVG icons in `frontend/static/degruyter-icons/`. Two families:
|
||||
|
||||
- **Simple** — single-color, action-oriented. Use for all UI icons. Available in 4 sizes.
|
||||
- **Complex** — multi-color illustrative icons. Use sparingly for empty states or section headers.
|
||||
|
||||
### Simple icon sizes
|
||||
|
||||
| Size | Pixels | Path segment | Use |
|
||||
|---|---|---|---|
|
||||
| XS | 12px | `X-Small-12px` | Inline text hints, badges |
|
||||
| SM | 16px | `Small-16px` | Compact UI, table cells |
|
||||
| **MD** | **24px** | **`Medium-24px`** | **Standard UI icon — default choice** |
|
||||
| LG | 32px | `Large-32px` | Feature headers, empty states |
|
||||
|
||||
### URL pattern
|
||||
|
||||
```
|
||||
/degruyter-icons/Simple/{Size}/SVG/{Category}/{Name}-{Size-Code}.svg
|
||||
```
|
||||
|
||||
**Size codes:** `XS`, `SM`, `MD`, `LG`
|
||||
|
||||
### Usage as `<img>` (recommended for static icons)
|
||||
|
||||
SVG fills are hardcoded to `#000000`. Use CSS to tint/size them:
|
||||
|
||||
```svelte
|
||||
<!-- Standard icon -->
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt="" aria-hidden="true" class="w-6 h-6" />
|
||||
|
||||
<!-- Muted/secondary icon -->
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt="" aria-hidden="true" class="w-6 h-6 opacity-40" />
|
||||
|
||||
<!-- Colored via CSS filter (navy tint) -->
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt="" aria-hidden="true"
|
||||
class="w-6 h-6"
|
||||
style="filter: invert(11%) sepia(58%) saturate(1200%) hue-rotate(192deg) brightness(95%) contrast(101%)" />
|
||||
```
|
||||
|
||||
> **Note:** Always include `alt=""` and `aria-hidden="true"` for decorative icons. For meaningful icons (no visible label next to them), use a descriptive `alt` text instead.
|
||||
|
||||
### Key icons for this app
|
||||
|
||||
| Use case | Icon path |
|
||||
|---|---|
|
||||
| Edit / Bearbeiten | `Action/Edit-Content-MD.svg` |
|
||||
| Search / Suche | `Action/Mag-Glass-MD.svg` |
|
||||
| New document | `Action/Add/Add-General-MD.svg` |
|
||||
| Download | `Action/Download-MD.svg` |
|
||||
| Upload | `Action/Upload-MD.svg` |
|
||||
| Filter | `Action/Filter/Filter-Outline-MD.svg` |
|
||||
| Calendar / date | `Action/Calendar/Calendar-Add-MD.svg` |
|
||||
| Location | `Action/Location-MD.svg` |
|
||||
| Person / account | `Action/Account-MD.svg` |
|
||||
| Chat / conversation | `Action/Chat-MD.svg` |
|
||||
| Tag / bookmark | `Action/Bookmark/Bookmark-Outline-MD.svg` |
|
||||
| Close / dismiss | `Action/Close-MD.svg` |
|
||||
| Back / left arrow | `Action/Arrow/Arrow-Left-MD.svg` |
|
||||
| Settings / admin | `Action/Settings-MD.svg` |
|
||||
| Document / PDF | `Action/PDF-Document-MD.svg` |
|
||||
| Mail | `Action/Mail-MD.svg` |
|
||||
| Delete | `Action/Remove/Remove-General-MD.svg` |
|
||||
| Info | `Action/Info/Block/Info-Block-Border-MD.svg` |
|
||||
|
||||
---
|
||||
|
||||
## Spacing
|
||||
|
||||
Based on Tailwind's 4pt grid. Prefer multiples of 4 for all spacing.
|
||||
|
||||
| Scale | Value | Use |
|
||||
|---|---|---|
|
||||
| `p-1` / `gap-1` | 4px | Tight inline spacing |
|
||||
| `p-2` | 8px | Small padding (badges, chips) |
|
||||
| `p-3` | 12px | Compact buttons |
|
||||
| `p-4` | 16px | Default section padding |
|
||||
| `p-6` | 24px | Card inner padding (default) |
|
||||
| `p-8` | 32px | Large card padding |
|
||||
| `p-10` | 40px | Page vertical padding |
|
||||
| `gap-6` | 24px | Grid/list gaps |
|
||||
| `mb-6` | 24px | Standard spacing between sections |
|
||||
| `mb-10` | 40px | Large spacing between card sections |
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### Page wrapper
|
||||
|
||||
All content pages use:
|
||||
```svelte
|
||||
<div class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||
```
|
||||
|
||||
Narrower pages (forms, detail views):
|
||||
```svelte
|
||||
<div class="max-w-4xl mx-auto py-10 px-4">
|
||||
```
|
||||
|
||||
### Header
|
||||
|
||||
The global sticky header in `+layout.svelte`:
|
||||
- Height: **68px** (4px purple accent strip + 64px nav bar)
|
||||
- Background: `bg-white`
|
||||
- Bottom border: `border-b border-gray-100`
|
||||
- Z-index: `z-50`
|
||||
|
||||
### Full-screen views
|
||||
|
||||
Document detail (`/documents/[id]`) uses a full-viewport split layout:
|
||||
```svelte
|
||||
<div class="h-screen flex flex-col bg-white">
|
||||
<!-- top bar -->
|
||||
<!-- content: sidebar + preview -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Card
|
||||
|
||||
Standard content card:
|
||||
```svelte
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">
|
||||
Section Title
|
||||
</h2>
|
||||
<!-- content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
Card with colored accent bar (person/document detail):
|
||||
```svelte
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm overflow-hidden">
|
||||
<div class="h-2 bg-brand-navy w-full"></div>
|
||||
<div class="p-8 md:p-10">
|
||||
<!-- content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Buttons
|
||||
|
||||
Primary button:
|
||||
```svelte
|
||||
<button class="bg-brand-navy text-white px-5 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors">
|
||||
Speichern
|
||||
</button>
|
||||
```
|
||||
|
||||
Secondary / outline button:
|
||||
```svelte
|
||||
<button class="border border-gray-300 text-gray-600 px-5 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-gray-50 transition-colors rounded-sm">
|
||||
Abbrechen
|
||||
</button>
|
||||
```
|
||||
|
||||
Ghost / text button (inline actions):
|
||||
```svelte
|
||||
<button class="text-brand-navy/60 hover:text-brand-navy text-sm font-medium font-sans transition-colors">
|
||||
Aktion
|
||||
</button>
|
||||
```
|
||||
|
||||
Destructive button:
|
||||
```svelte
|
||||
<button class="border border-red-300 text-red-600 px-4 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-red-50 transition-colors rounded-sm">
|
||||
Löschen
|
||||
</button>
|
||||
```
|
||||
|
||||
Button with DGB icon:
|
||||
```svelte
|
||||
<button class="inline-flex items-center gap-2 bg-brand-navy text-white px-4 py-2 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors">
|
||||
<img src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
|
||||
alt="" aria-hidden="true" class="w-4 h-4 invert" />
|
||||
Bearbeiten
|
||||
</button>
|
||||
```
|
||||
|
||||
> Use `class="invert"` on icons inside dark (navy) buttons to make the black SVG white.
|
||||
|
||||
### Form inputs
|
||||
|
||||
Label + input pair:
|
||||
```svelte
|
||||
<div>
|
||||
<label for="field" class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-1.5 font-sans">
|
||||
Feldname *
|
||||
</label>
|
||||
<input
|
||||
id="field" name="field" type="text"
|
||||
class="block w-full border border-gray-300 py-2.5 px-3 text-sm font-serif text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
Search input:
|
||||
```svelte
|
||||
<div class="relative">
|
||||
<input type="text" placeholder="Suchen..."
|
||||
class="block w-full border border-gray-300 py-2.5 pr-10 pl-3 font-sans text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" />
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||
alt="" aria-hidden="true" class="w-4 h-4 opacity-40" />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Status badges
|
||||
|
||||
```svelte
|
||||
<!-- Mint (uploaded/active) -->
|
||||
<span class="inline-flex items-center rounded-full border border-brand-mint/50 bg-brand-mint/20 text-brand-navy px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase font-sans">
|
||||
UPLOADED
|
||||
</span>
|
||||
|
||||
<!-- Yellow (placeholder/pending) -->
|
||||
<span class="inline-flex items-center rounded-full border border-yellow-200 bg-yellow-50 text-yellow-700 px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase font-sans">
|
||||
PLACEHOLDER
|
||||
</span>
|
||||
```
|
||||
|
||||
### Tag chips
|
||||
|
||||
```svelte
|
||||
<button class="inline-flex items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase font-sans transition-colors hover:bg-brand-navy hover:text-white">
|
||||
Schlagwort
|
||||
</button>
|
||||
```
|
||||
|
||||
### Back link
|
||||
|
||||
```svelte
|
||||
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4 font-sans">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt="" aria-hidden="true"
|
||||
class="w-4 h-4 mr-2 opacity-40 group-hover:opacity-100 transition-opacity" />
|
||||
Zurück zur Übersicht
|
||||
</a>
|
||||
```
|
||||
|
||||
### Subtle "new item" link
|
||||
|
||||
```svelte
|
||||
<a href="/documents/new" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors font-sans">
|
||||
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt="" aria-hidden="true" class="w-4 h-4 opacity-60" />
|
||||
Neues Dokument
|
||||
</a>
|
||||
```
|
||||
|
||||
### Empty state
|
||||
|
||||
```svelte
|
||||
<div class="p-16 text-center">
|
||||
<div class="mx-auto mb-4 w-16 h-16 flex items-center justify-center">
|
||||
<img src="/degruyter-icons/Simple/Large-32px/SVG/Action/Mag-Glass-LG.svg"
|
||||
alt="" aria-hidden="true" class="w-10 h-10 opacity-20" />
|
||||
</div>
|
||||
<h3 class="font-serif text-lg font-medium text-brand-navy">Keine Dokumente gefunden</h3>
|
||||
<p class="mt-1 font-sans text-sm text-gray-500">Versuchen Sie, die Filter anzupassen.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Nav active state
|
||||
|
||||
Current page nav link:
|
||||
```svelte
|
||||
class="text-brand-navy bg-brand-purple/15 rounded"
|
||||
```
|
||||
|
||||
Inactive nav link:
|
||||
```svelte
|
||||
class="text-gray-500 hover:text-brand-navy hover:bg-brand-sand/60 rounded"
|
||||
```
|
||||
|
||||
Both share the base: `inline-flex items-center px-3 py-1.5 text-xs font-bold uppercase tracking-widest font-sans transition-colors`
|
||||
|
||||
### Save bar (long forms)
|
||||
|
||||
Sticky full-bleed (document edit):
|
||||
```svelte
|
||||
<div class="sticky bottom-0 z-10 -mx-4 px-6 py-4 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] flex items-center justify-between">
|
||||
<a href="..." class="text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy font-sans transition-colors">
|
||||
Abbrechen
|
||||
</a>
|
||||
<button type="submit" class="bg-brand-navy text-white px-6 py-2.5 text-xs font-bold uppercase tracking-widest font-sans hover:bg-brand-navy/90 transition-colors">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
Card-style (short forms):
|
||||
```svelte
|
||||
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Do / Don't
|
||||
|
||||
| Do | Don't |
|
||||
|---|---|
|
||||
| Use `font-sans` for all UI labels, buttons, nav | Use `font-serif` for buttons or labels |
|
||||
| Use `uppercase tracking-widest` for labels | Use sentence case for field labels |
|
||||
| Use `brand-navy` for primary actions | Use `brand-mint` as primary action color |
|
||||
| Use `opacity-*` to create icon tints | Change icon fill color inline |
|
||||
| Use `invert` class on icons inside `bg-brand-navy` buttons | Use colored icon files for button icons |
|
||||
| Use `rounded-sm` for cards and buttons (subtle) | Use `rounded-full` for non-pill elements |
|
||||
| Use `shadow-sm` for card elevation | Use large shadows |
|
||||
| Keep borders `border-gray-100` or `border-brand-sand` | Use dark borders |
|
||||
96
docs/mail.md
Normal file
96
docs/mail.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Mail configuration
|
||||
|
||||
Familienarchiv uses Spring Mail to send password reset emails. The mail sender is **optional** — if no SMTP host is configured, the feature degrades gracefully: a reset token is still created in the database, but no email is sent and a warning is logged.
|
||||
|
||||
## How it works in each environment
|
||||
|
||||
| Environment | Default behaviour |
|
||||
|---|---|
|
||||
| `docker-compose up` (dev) | Mailpit catches all emails — nothing leaves your machine |
|
||||
| CI | No mail host set — emails are silently skipped, tokens tested via the `/api/auth/reset-token-for-test` endpoint |
|
||||
| Production | Real SMTP server configured via environment variables |
|
||||
|
||||
---
|
||||
|
||||
## Development — Mailpit
|
||||
|
||||
[Mailpit](https://github.com/axllent/mailpit) is included in `docker-compose.yml` as a local mail catcher. It accepts SMTP connections from the backend and displays all caught emails in a web inbox. No credentials or external network access required.
|
||||
|
||||
**Start the stack as usual:**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**Open the inbox:**
|
||||
|
||||
```
|
||||
http://localhost:8025
|
||||
```
|
||||
|
||||
All password reset emails appear here. Copy the reset link from the email body and open it in your browser to complete the flow end-to-end locally.
|
||||
|
||||
**Ports (configurable in `.env`):**
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `PORT_MAILPIT_UI` | `8025` | Mailpit web inbox |
|
||||
| `PORT_MAILPIT_SMTP` | `1025` | SMTP port (used internally by the backend) |
|
||||
|
||||
---
|
||||
|
||||
## Production — real SMTP
|
||||
|
||||
To send real emails, set the following variables in your `.env` file (or as host environment variables). The `MAIL_HOST` variable is the switch — leaving it empty disables outgoing mail entirely.
|
||||
|
||||
```dotenv
|
||||
# Required
|
||||
APP_BASE_URL=https://your-domain.example.com # Base URL inserted into reset links
|
||||
MAIL_HOST=smtp.example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=your-smtp-user
|
||||
MAIL_PASSWORD=your-smtp-password
|
||||
|
||||
# Optional — adjust if your provider uses different settings
|
||||
MAIL_SMTP_AUTH=true # default: false (Mailpit needs false)
|
||||
MAIL_STARTTLS_ENABLE=true # default: false (Mailpit needs false)
|
||||
APP_MAIL_FROM=noreply@your-domain.example.com
|
||||
```
|
||||
|
||||
**Common provider settings:**
|
||||
|
||||
| Provider | Host | Port | Auth | STARTTLS |
|
||||
|---|---|---|---|---|
|
||||
| Gmail (App Password) | `smtp.gmail.com` | `587` | `true` | `true` |
|
||||
| Mailgun | `smtp.mailgun.org` | `587` | `true` | `true` |
|
||||
| Hetzner | `mail.your-server.de` | `587` | `true` | `true` |
|
||||
| Self-hosted Postfix | your server IP/hostname | `587` | `true` | `true` |
|
||||
|
||||
> **Gmail note:** You must use an [App Password](https://support.google.com/accounts/answer/185833), not your regular account password. 2-Step Verification must be enabled on the account.
|
||||
|
||||
---
|
||||
|
||||
## Environment variable reference
|
||||
|
||||
All variables have safe defaults so the app starts without any mail configuration.
|
||||
|
||||
| Variable | Default (docker-compose) | Description |
|
||||
|---|---|---|
|
||||
| `MAIL_HOST` | `mailpit` | SMTP hostname. Empty string disables mail entirely. |
|
||||
| `MAIL_PORT` | `1025` | SMTP port. |
|
||||
| `MAIL_USERNAME` | *(empty)* | SMTP username. Leave empty if your server needs no auth. |
|
||||
| `MAIL_PASSWORD` | *(empty)* | SMTP password. |
|
||||
| `MAIL_SMTP_AUTH` | `false` | Enable SMTP authentication (`true` for real servers). |
|
||||
| `MAIL_STARTTLS_ENABLE` | `false` | Enable STARTTLS (`true` for real servers on port 587). |
|
||||
| `APP_MAIL_FROM` | `noreply@familienarchiv.local` | The `From:` address on outgoing emails. |
|
||||
| `APP_BASE_URL` | `http://localhost:3000` | Base URL prepended to password reset links. |
|
||||
|
||||
---
|
||||
|
||||
## Disabling mail entirely
|
||||
|
||||
Set `MAIL_HOST` to an empty string. Spring Boot will not create a mail sender bean and no emails will be sent. Password reset tokens are still written to the database — useful if you want to test the reset flow via the API directly.
|
||||
|
||||
```dotenv
|
||||
MAIL_HOST=
|
||||
```
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -28,3 +28,4 @@ src/lib/paraglide
|
||||
# Generated OpenAPI types — regenerate with: npm run generate:api
|
||||
# (committed as a stub; overwritten by the real spec after generation)
|
||||
# src/lib/generated/api.ts
|
||||
src/lib/paraglide_bak*
|
||||
|
||||
1
frontend/.husky/pre-commit
Normal file
1
frontend/.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npm test
|
||||
@@ -7,3 +7,12 @@ bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
|
||||
# Generated files
|
||||
/src/lib/generated/
|
||||
/src/lib/paraglide/
|
||||
/src/lib/paraglide_bak*/
|
||||
|
||||
# Test artifacts
|
||||
/test-results/
|
||||
/e2e/.auth/
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
|
||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies as a separate layer so they are cached when only source changes
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Source is mounted at runtime via docker-compose volume
|
||||
# This COPY is only used when building without a volume (e.g. production image)
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
0
frontend/e2e/.auth/.gitkeep
Normal file
0
frontend/e2e/.auth/.gitkeep
Normal file
25
frontend/e2e/.auth/user.json
Normal file
25
frontend/e2e/.auth/user.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "PARAGLIDE_LOCALE",
|
||||
"value": "de",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1808896929.897686,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "auth_token",
|
||||
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1774423330.233039,
|
||||
"httpOnly": true,
|
||||
"secure": false,
|
||||
"sameSite": "Strict"
|
||||
}
|
||||
],
|
||||
"origins": []
|
||||
}
|
||||
250
frontend/e2e/admin.spec.ts
Normal file
250
frontend/e2e/admin.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { test, expect, type Browser } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Admin panel E2E tests.
|
||||
*
|
||||
* Reads top-to-bottom as a complete admin journey:
|
||||
* 1. Admin opens the dashboard and sees all three management tabs.
|
||||
* 2. Admin creates a group for read-only access.
|
||||
* 3. Admin creates a new user in that group.
|
||||
* 4. Admin edits the user's profile.
|
||||
* 5. Admin resets the user's password without knowing their current password.
|
||||
* 6. The user can log in with the admin-set password.
|
||||
* 7. Admin deletes the user.
|
||||
* 8. Admin deletes the test group.
|
||||
* 9. Admin renames a tag and renames it back.
|
||||
*
|
||||
* Steps 2–8 form a self-contained lifecycle: everything created in this suite
|
||||
* is also deleted, leaving the database in its original state.
|
||||
*/
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin dashboard', () => {
|
||||
test('admin navigates to /admin and sees the three management tabs', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Benutzer', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Gruppen', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Schlagworte', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-dashboard.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin — group management', () => {
|
||||
test('admin creates a new group "E2E Leser" with READ_ALL permission', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Switch to the Groups tab
|
||||
await page.getByRole('button', { name: 'Gruppen', exact: true }).click();
|
||||
|
||||
await page.getByPlaceholder('Gruppenname (z.B. Editoren)').fill('E2E Leser');
|
||||
|
||||
// No permission checkboxes checked — READ_ALL is handled at application level
|
||||
// (a group with no permissions gets read-only access by default in the UI)
|
||||
|
||||
await page.getByRole('button', { name: /Erstellen/i }).click();
|
||||
|
||||
await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-group-created.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── User lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin — user lifecycle', () => {
|
||||
test('admin creates user "e2e-testuser" and they appear in the user list', async ({ page }) => {
|
||||
await page.goto('/admin/users/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.locator('input[name="username"]').fill('e2e-testuser');
|
||||
await page.locator('input[name="password"]').fill('InitPass123!');
|
||||
|
||||
// Assign to the group we just created
|
||||
const groupLabel = page.locator('label').filter({ hasText: 'E2E Leser' });
|
||||
if ((await groupLabel.count()) > 0) {
|
||||
await groupLabel.locator('input[type="checkbox"]').check();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /Erstellen/i }).click();
|
||||
|
||||
// Redirected back to /admin — user appears in the table
|
||||
await expect(page).toHaveURL('/admin');
|
||||
await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-user-created.png' });
|
||||
});
|
||||
|
||||
test('admin opens the edit page and updates the user first name', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Click the edit link for the test user
|
||||
const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' });
|
||||
await userRow.getByRole('link', { name: /Bearbeiten/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users\/.+/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Benutzer bearbeiten: e2e-testuser/i })
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('input[name="firstName"]').fill('E2E');
|
||||
await page.locator('input[name="lastName"]').fill('Testuser');
|
||||
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
|
||||
await expect(page.getByText('Änderungen gespeichert.')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-user-edited.png' });
|
||||
});
|
||||
|
||||
test('admin sets a new password without entering the current password', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' });
|
||||
await userRow.getByRole('link', { name: /Bearbeiten/i }).click();
|
||||
|
||||
// Password fields — no current password field on the admin edit form
|
||||
await page.locator('input[name="newPassword"]').fill('AdminSet456!');
|
||||
await page.locator('input[name="confirmPassword"]').fill('AdminSet456!');
|
||||
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
|
||||
await expect(page.getByText('Änderungen gespeichert.')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-user-password-reset.png' });
|
||||
});
|
||||
|
||||
test('the user can log in with the admin-set password', async ({ browser }) => {
|
||||
// Open a completely separate browser context — no shared session cookies
|
||||
const freshCtx = await (browser as Browser).newContext({
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const freshPage = await freshCtx.newPage();
|
||||
|
||||
await freshPage.goto('/login');
|
||||
await freshPage.getByLabel('Benutzername').fill('e2e-testuser');
|
||||
await freshPage.getByLabel('Passwort').fill('AdminSet456!');
|
||||
await freshPage.getByRole('button', { name: 'Anmelden' }).click();
|
||||
|
||||
await expect(freshPage).toHaveURL('/');
|
||||
await freshPage.screenshot({ path: 'test-results/e2e/admin-user-login-new-password.png' });
|
||||
|
||||
await freshCtx.close();
|
||||
});
|
||||
|
||||
test('admin deletes the test user and they disappear from the list', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const userRow = page.locator('tr').filter({ hasText: 'e2e-testuser' });
|
||||
|
||||
// The delete button triggers a window.confirm() dialog
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await userRow.getByTitle('Benutzer löschen').click();
|
||||
|
||||
await expect(page.getByRole('cell', { name: 'e2e-testuser', exact: true })).not.toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-user-deleted.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group cleanup ──────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin — group cleanup', () => {
|
||||
test('admin deletes the "E2E Leser" group', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: 'Gruppen' }).click();
|
||||
|
||||
const groupRow = page.locator('tr').filter({ hasText: 'E2E Leser' });
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await groupRow.getByTitle('Löschen').click();
|
||||
|
||||
await expect(page.getByRole('cell', { name: 'E2E Leser', exact: true })).not.toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-group-deleted.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tag management ─────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Admin — tag management', () => {
|
||||
test('admin renames a tag and sees the change in the list', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: 'Schlagworte', exact: true }).click();
|
||||
// Wait for the tags list to render after the tab switch
|
||||
await page.waitForSelector('ul > li');
|
||||
|
||||
// Hover over the "Familie" row to reveal the opacity-0 action buttons
|
||||
const familieRow = page
|
||||
.locator('ul > li')
|
||||
.filter({ has: page.locator('span', { hasText: /^Familie$/ }) });
|
||||
await familieRow.hover();
|
||||
await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
|
||||
|
||||
// After clicking edit, {#if editingTagId} replaces the span with a form —
|
||||
// the familieRow filter no longer matches, so we find the input directly.
|
||||
await page.locator('input[name="name"]').fill('Familie (E2E)');
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
|
||||
await expect(page.getByText('Familie (E2E)')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' });
|
||||
});
|
||||
|
||||
test('admin renames it back to restore the original name', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: 'Schlagworte', exact: true }).click();
|
||||
await page.waitForSelector('ul > li');
|
||||
|
||||
const renamedRow = page
|
||||
.locator('ul > li')
|
||||
.filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) });
|
||||
await renamedRow.hover();
|
||||
await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
|
||||
|
||||
await page.locator('input[name="name"]').fill('Familie');
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
|
||||
await expect(page.getByText('Familie')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── System tab — backfill file hashes ────────────────────────────────────────
|
||||
|
||||
test.describe('Admin system tab — backfill file hashes', () => {
|
||||
test('admin triggers file hash backfill and sees success message', async ({ request, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// Create a document via API so there is at least one without a hash
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Backfill Hash Test' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Navigate to System tab
|
||||
await page.getByRole('button', { name: /system/i }).click();
|
||||
|
||||
// Click the backfill hashes button
|
||||
const btn = page.getByRole('button', { name: /datei-hashes berechnen/i });
|
||||
await expect(btn).toBeVisible();
|
||||
await btn.click();
|
||||
|
||||
// Success message must appear (count >= 0)
|
||||
await expect(page.locator('text=/\\d+ Dokumente wurden aktualisiert/i')).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user