Compare commits
1 Commits
d4f666e981
...
docs/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd7f0d486 |
@@ -47,26 +47,6 @@ jobs:
|
|||||||
name: unit-test-screenshots
|
name: unit-test-screenshots
|
||||||
path: frontend/test-results/screenshots/
|
path: frontend/test-results/screenshots/
|
||||||
|
|
||||||
# ─── OCR Service Unit Tests ───────────────────────────────────────────────────
|
|
||||||
# Only spell_check.py, test_confidence.py, test_sender_registry.py — no ML stack required.
|
|
||||||
ocr-tests:
|
|
||||||
name: OCR Service Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Install test dependencies
|
|
||||||
run: pip install "pyspellchecker==0.9.0" pytest pytest-asyncio
|
|
||||||
working-directory: ocr-service
|
|
||||||
|
|
||||||
- name: Run OCR unit tests (no ML stack required)
|
|
||||||
run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py -v
|
|
||||||
working-directory: ocr-service
|
|
||||||
|
|
||||||
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
|
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
|
||||||
# Pure Mockito + WebMvcTest — no DB or S3 needed.
|
# Pure Mockito + WebMvcTest — no DB or S3 needed.
|
||||||
backend-unit-tests:
|
backend-unit-tests:
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -11,9 +11,4 @@ gitea/
|
|||||||
scripts/large-data.sql
|
scripts/large-data.sql
|
||||||
|
|
||||||
.vitest-attachments
|
.vitest-attachments
|
||||||
**/test-results/
|
**/test-results/
|
||||||
.worktrees/
|
|
||||||
.superpowers/
|
|
||||||
|
|
||||||
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
|
||||||
frontend/yarn.lock
|
|
||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -311,15 +311,13 @@ Save bar pattern — use **sticky full-bleed** for long forms (edit document), *
|
|||||||
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
|
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
|
||||||
```
|
```
|
||||||
|
|
||||||
Back button pattern — use the shared `<BackButton>` component from `$lib/components/BackButton.svelte`:
|
Back link pattern:
|
||||||
```svelte
|
```svelte
|
||||||
<script lang="ts">
|
<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">
|
||||||
import BackButton from '$lib/components/BackButton.svelte';
|
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" .../>
|
||||||
</script>
|
Zurück zur Übersicht
|
||||||
|
</a>
|
||||||
<BackButton />
|
|
||||||
```
|
```
|
||||||
The component calls `history.back()` so the user returns to wherever they came from. Label is always "Zurück" (no contextual suffix — destination is unknown). Touch target ≥ 44px and focus ring are built in. Do not use a static `<a href>` for back navigation.
|
|
||||||
|
|
||||||
Subtle action link (e.g. "new document/person"):
|
Subtle action link (e.g. "new document/person"):
|
||||||
```svelte
|
```svelte
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
### Mark all blocks as reviewed
|
|
||||||
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
|
|
||||||
Authorization: Basic admin admin123
|
|
||||||
@@ -103,11 +103,6 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.awaitility</groupId>
|
|
||||||
<artifactId>awaitility</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Excel Bearbeitung (Apache POI) -->
|
<!-- Excel Bearbeitung (Apache POI) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -151,12 +146,6 @@
|
|||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Caffeine cache for in-memory rate limiting -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
|
||||||
<artifactId>caffeine</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
@@ -164,26 +153,12 @@
|
|||||||
<version>3.0.2</version>
|
<version>3.0.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- PDF rendering for training data export and thumbnail generation -->
|
<!-- PDF rendering for training data export -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.pdfbox</groupId>
|
<groupId>org.apache.pdfbox</groupId>
|
||||||
<artifactId>pdfbox</artifactId>
|
<artifactId>pdfbox</artifactId>
|
||||||
<version>3.0.4</version>
|
<version>3.0.4</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- TIFF decoding plugin for ImageIO (thumbnail generation from scanned TIFFs) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.twelvemonkeys.imageio</groupId>
|
|
||||||
<artifactId>imageio-tiff</artifactId>
|
|
||||||
<version>3.12.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- HTML sanitization for Geschichten rich-text body (defense-in-depth alongside Tiptap on the client) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
|
|
||||||
<artifactId>owasp-java-html-sanitizer</artifactId>
|
|
||||||
<version>20240325.1</version>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.annotation.Nullable;
|
|
||||||
|
|
||||||
public record ActivityActorDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String initials,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String color,
|
|
||||||
@Nullable String name
|
|
||||||
) {}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface ActivityFeedRow {
|
|
||||||
String getKind();
|
|
||||||
UUID getActorId();
|
|
||||||
String getActorInitials();
|
|
||||||
String getActorColor();
|
|
||||||
String getActorName();
|
|
||||||
UUID getDocumentId();
|
|
||||||
Instant getHappenedAt();
|
|
||||||
boolean isYouMentioned();
|
|
||||||
boolean isYouParticipated();
|
|
||||||
int getCount();
|
|
||||||
Instant getHappenedAtUntil();
|
|
||||||
/** Present only for COMMENT_ADDED and MENTION_CREATED — null otherwise. */
|
|
||||||
UUID getCommentId();
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
public enum AuditKind {
|
|
||||||
|
|
||||||
/** Payload: none */
|
|
||||||
FILE_UPLOADED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"oldStatus": "UPLOADED", "newStatus": "TRANSCRIBED"}} */
|
|
||||||
STATUS_CHANGED,
|
|
||||||
|
|
||||||
/** Payload: none */
|
|
||||||
METADATA_UPDATED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"pageNumber": 3}} */
|
|
||||||
TEXT_SAVED,
|
|
||||||
|
|
||||||
/** Payload: none */
|
|
||||||
BLOCK_REVIEWED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"pageNumber": 3}} */
|
|
||||||
ANNOTATION_CREATED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"commentId": "uuid"}} */
|
|
||||||
COMMENT_ADDED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
|
||||||
MENTION_CREATED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
|
||||||
USER_CREATED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
|
||||||
USER_DELETED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
|
||||||
GROUP_MEMBERSHIP_CHANGED;
|
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
|
||||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.*;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
import org.hibernate.annotations.JdbcTypeCode;
|
|
||||||
import org.hibernate.type.SqlTypes;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "audit_log")
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class AuditLog {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(name = "happened_at", nullable = false, updatable = false)
|
|
||||||
@CreationTimestamp
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private OffsetDateTime happenedAt;
|
|
||||||
|
|
||||||
@Column(name = "actor_id")
|
|
||||||
private UUID actorId;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "kind", nullable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private AuditKind kind;
|
|
||||||
|
|
||||||
@Column(name = "document_id")
|
|
||||||
private UUID documentId;
|
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
|
||||||
@Column(columnDefinition = "jsonb")
|
|
||||||
private Map<String, Object> payload;
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
SELECT a.document_id
|
|
||||||
FROM audit_log a
|
|
||||||
WHERE a.kind IN ('TEXT_SAVED', 'ANNOTATION_CREATED')
|
|
||||||
AND a.actor_id = :userId
|
|
||||||
AND a.document_id IS NOT NULL
|
|
||||||
ORDER BY a.happened_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", nativeQuery = true)
|
|
||||||
Optional<UUID> findMostRecentDocumentIdByActor(@Param("userId") UUID userId);
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
WITH events AS (
|
|
||||||
SELECT
|
|
||||||
a.kind,
|
|
||||||
a.actor_id,
|
|
||||||
a.document_id,
|
|
||||||
a.happened_at,
|
|
||||||
a.payload,
|
|
||||||
LAG(a.happened_at) OVER (
|
|
||||||
PARTITION BY a.actor_id, a.document_id, a.kind
|
|
||||||
ORDER BY a.happened_at
|
|
||||||
) AS prev_happened_at
|
|
||||||
FROM audit_log a
|
|
||||||
WHERE a.kind IN (:kinds)
|
|
||||||
AND a.document_id IS NOT NULL
|
|
||||||
),
|
|
||||||
sessions_marked AS (
|
|
||||||
SELECT
|
|
||||||
kind, actor_id, document_id, happened_at, payload,
|
|
||||||
CASE
|
|
||||||
WHEN kind IN ('COMMENT_ADDED','MENTION_CREATED') THEN 1
|
|
||||||
WHEN prev_happened_at IS NULL THEN 1
|
|
||||||
WHEN EXTRACT(EPOCH FROM (happened_at - prev_happened_at)) > 7200 THEN 1
|
|
||||||
ELSE 0
|
|
||||||
END AS is_new_session
|
|
||||||
FROM events
|
|
||||||
),
|
|
||||||
sessions AS (
|
|
||||||
SELECT
|
|
||||||
kind, actor_id, document_id, happened_at, payload,
|
|
||||||
SUM(is_new_session) OVER (
|
|
||||||
PARTITION BY actor_id, document_id, kind
|
|
||||||
ORDER BY happened_at
|
|
||||||
ROWS UNBOUNDED PRECEDING
|
|
||||||
) AS session_id
|
|
||||||
FROM sessions_marked
|
|
||||||
),
|
|
||||||
aggregated AS (
|
|
||||||
SELECT
|
|
||||||
s.kind,
|
|
||||||
s.actor_id,
|
|
||||||
s.document_id,
|
|
||||||
s.session_id,
|
|
||||||
MIN(s.happened_at) AS happened_at,
|
|
||||||
CASE WHEN COUNT(*) > 1 THEN MAX(s.happened_at) ELSE NULL END AS happened_at_until,
|
|
||||||
COUNT(*)::int AS count,
|
|
||||||
BOOL_OR(s.kind = 'MENTION_CREATED'
|
|
||||||
AND s.payload->>'mentionedUserId' = :currentUserId) AS you_mentioned,
|
|
||||||
-- COMMENT_ADDED/MENTION_CREATED always have is_new_session=1, so each group has one row and MIN collapses to that row payload
|
|
||||||
MIN(s.payload::text)::jsonb AS payload
|
|
||||||
FROM sessions s
|
|
||||||
GROUP BY s.kind, s.actor_id, s.document_id, s.session_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
ag.kind AS kind,
|
|
||||||
ag.actor_id AS actorId,
|
|
||||||
CASE
|
|
||||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
|
||||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
|
||||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
|
||||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
|
||||||
ELSE '?'
|
|
||||||
END AS actorInitials,
|
|
||||||
COALESCE(u.color, '') AS actorColor,
|
|
||||||
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
|
|
||||||
ag.document_id AS documentId,
|
|
||||||
ag.happened_at AS happened_at,
|
|
||||||
ag.you_mentioned AS youMentioned,
|
|
||||||
-- payload->>'commentId' matches notifications.reference_id per AuditKind.COMMENT_ADDED contract
|
|
||||||
EXISTS(
|
|
||||||
SELECT 1 FROM notifications n
|
|
||||||
WHERE n.type = 'REPLY'
|
|
||||||
AND n.recipient_id = CAST(:currentUserId AS uuid)
|
|
||||||
AND n.reference_id = (ag.payload->>'commentId')::uuid
|
|
||||||
) AS youParticipated,
|
|
||||||
ag.count AS count,
|
|
||||||
ag.happened_at_until AS happenedAtUntil,
|
|
||||||
(ag.payload->>'commentId')::uuid AS commentId
|
|
||||||
FROM aggregated ag
|
|
||||||
LEFT JOIN users u ON u.id = ag.actor_id
|
|
||||||
ORDER BY ag.happened_at DESC
|
|
||||||
LIMIT :limit
|
|
||||||
""", nativeQuery = true)
|
|
||||||
List<ActivityFeedRow> findRolledUpActivityFeed(
|
|
||||||
@Param("currentUserId") String currentUserId,
|
|
||||||
@Param("limit") int limit,
|
|
||||||
@Param("kinds") Collection<String> kinds);
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
SELECT
|
|
||||||
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) AS pages,
|
|
||||||
COUNT(*) FILTER (WHERE a.kind = 'ANNOTATION_CREATED') AS annotated,
|
|
||||||
COUNT(DISTINCT a.payload->>'blockId') FILTER (WHERE a.kind = 'TEXT_SAVED') AS transcribed,
|
|
||||||
COUNT(DISTINCT a.document_id) FILTER (WHERE a.kind = 'FILE_UPLOADED') AS uploaded,
|
|
||||||
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber')))
|
|
||||||
FILTER (WHERE (a.kind = 'ANNOTATION_CREATED' OR a.kind = 'TEXT_SAVED')
|
|
||||||
AND a.actor_id::text = :userId) AS yourPages
|
|
||||||
FROM audit_log a
|
|
||||||
WHERE a.happened_at >= :weekStart
|
|
||||||
AND a.kind IN ('ANNOTATION_CREATED','TEXT_SAVED','FILE_UPLOADED')
|
|
||||||
""", nativeQuery = true)
|
|
||||||
PulseStatsRow getPulseStats(
|
|
||||||
@Param("weekStart") OffsetDateTime weekStart,
|
|
||||||
@Param("userId") String userId);
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
SELECT DISTINCT ON (a.document_id)
|
|
||||||
a.document_id AS documentId,
|
|
||||||
a.actor_id AS actorId
|
|
||||||
FROM audit_log a
|
|
||||||
WHERE a.kind = :kind
|
|
||||||
AND a.document_id IN :documentIds
|
|
||||||
AND a.actor_id IS NOT NULL
|
|
||||||
ORDER BY a.document_id, a.happened_at DESC
|
|
||||||
""", nativeQuery = true)
|
|
||||||
List<Object[]> findMostRecentActorPerDocument(
|
|
||||||
@Param("documentIds") List<UUID> documentIds,
|
|
||||||
@Param("kind") String kind);
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
SELECT
|
|
||||||
a.document_id AS documentId,
|
|
||||||
CASE
|
|
||||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
|
||||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
|
||||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
|
||||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
|
||||||
ELSE '?'
|
|
||||||
END AS actorInitials,
|
|
||||||
COALESCE(u.color, '') AS actorColor,
|
|
||||||
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName
|
|
||||||
FROM audit_log a
|
|
||||||
LEFT JOIN users u ON u.id = a.actor_id
|
|
||||||
WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED')
|
|
||||||
AND a.document_id IN :documentIds
|
|
||||||
AND a.actor_id IS NOT NULL
|
|
||||||
GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color
|
|
||||||
ORDER BY a.document_id, MIN(a.happened_at)
|
|
||||||
""", nativeQuery = true)
|
|
||||||
List<ContributorRow> findContributorsPerDocument(@Param("documentIds") List<UUID> documentIds);
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
SELECT
|
|
||||||
ranked.document_id AS documentId,
|
|
||||||
ranked.actorInitials AS actorInitials,
|
|
||||||
ranked.actorColor AS actorColor,
|
|
||||||
ranked.actorName AS actorName
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
a.document_id,
|
|
||||||
CASE
|
|
||||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
|
||||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
|
||||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
|
||||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
|
||||||
ELSE '?'
|
|
||||||
END AS actorInitials,
|
|
||||||
COALESCE(u.color, '') AS actorColor,
|
|
||||||
NULLIF(CONCAT_WS(' ', u.first_name, u.last_name), '') AS actorName,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY a.document_id
|
|
||||||
ORDER BY MAX(a.happened_at) DESC
|
|
||||||
) AS rn
|
|
||||||
FROM audit_log a
|
|
||||||
LEFT JOIN users u ON u.id = a.actor_id
|
|
||||||
WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED')
|
|
||||||
AND a.document_id IN :documentIds
|
|
||||||
AND a.actor_id IS NOT NULL
|
|
||||||
GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color
|
|
||||||
) ranked
|
|
||||||
WHERE ranked.rn <= 4
|
|
||||||
ORDER BY ranked.document_id, ranked.rn
|
|
||||||
""", nativeQuery = true)
|
|
||||||
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
|
||||||
|
|
||||||
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
|
|
||||||
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
|
|
||||||
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AuditLogQueryService {
|
|
||||||
|
|
||||||
private final AuditLogQueryRepository queryRepository;
|
|
||||||
|
|
||||||
public Optional<UUID> findMostRecentDocumentForUser(UUID userId) {
|
|
||||||
return queryRepository.findMostRecentDocumentIdByActor(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit) {
|
|
||||||
return findActivityFeed(currentUserId, limit, AuditKind.ROLLUP_ELIGIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit, Set<AuditKind> kinds) {
|
|
||||||
List<String> kindNames = kinds.stream().map(Enum::name).toList();
|
|
||||||
return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit, kindNames);
|
|
||||||
}
|
|
||||||
|
|
||||||
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {
|
|
||||||
return queryRepository.getPulseStats(weekStart, userId.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<UUID, UUID> findMostRecentActorPerDocument(List<UUID> documentIds, String kind) {
|
|
||||||
if (documentIds.isEmpty()) return Map.of();
|
|
||||||
List<Object[]> rows = queryRepository.findMostRecentActorPerDocument(documentIds, kind);
|
|
||||||
Map<UUID, UUID> result = new LinkedHashMap<>();
|
|
||||||
for (Object[] row : rows) {
|
|
||||||
UUID docId = (UUID) row[0];
|
|
||||||
UUID actorId = (UUID) row[1];
|
|
||||||
result.put(docId, actorId);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
|
|
||||||
if (documentIds.isEmpty()) return Map.of();
|
|
||||||
return toContributorMap(queryRepository.findContributorsPerDocument(documentIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<UUID, List<ActivityActorDTO>> findRecentContributorsPerDocument(List<UUID> documentIds) {
|
|
||||||
if (documentIds.isEmpty()) return Map.of();
|
|
||||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<AuditLog> findRecentUserManagementEvents(int limit) {
|
|
||||||
PageRequest page = PageRequest.of(0, limit, Sort.by("happenedAt").descending());
|
|
||||||
return queryRepository.findByKindIn(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), page).getContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
|
||||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
|
||||||
for (ContributorRow row : rows) {
|
|
||||||
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())
|
|
||||||
.add(new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName()));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
|
||||||
boolean existsByKind(AuditKind kind);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.core.task.TaskExecutor;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class AuditService {
|
|
||||||
|
|
||||||
private final AuditLogRepository auditLogRepository;
|
|
||||||
@Qualifier("auditExecutor")
|
|
||||||
private final TaskExecutor auditExecutor;
|
|
||||||
|
|
||||||
@Async("auditExecutor")
|
|
||||||
public void log(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
|
|
||||||
writeLog(kind, actorId, documentId, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void logAfterCommit(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
|
|
||||||
if (TransactionSynchronizationManager.isActualTransactionActive()) {
|
|
||||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
|
||||||
@Override
|
|
||||||
public void afterCommit() {
|
|
||||||
// Run on a separate thread: the afterCommit() callback fires while Spring's
|
|
||||||
// transaction synchronizations are still active on the current thread, which
|
|
||||||
// prevents SimpleJpaRepository.save() from starting a new transaction inline.
|
|
||||||
auditExecutor.execute(() -> writeLog(kind, actorId, documentId, payload));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
writeLog(kind, actorId, documentId, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeLog(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
|
|
||||||
try {
|
|
||||||
auditLogRepository.save(AuditLog.builder()
|
|
||||||
.kind(kind)
|
|
||||||
.actorId(actorId)
|
|
||||||
.documentId(documentId)
|
|
||||||
.payload(payload)
|
|
||||||
.build());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Audit log write failed: kind={}, document={}", kind, documentId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface ContributorRow {
|
|
||||||
UUID getDocumentId();
|
|
||||||
String getActorInitials();
|
|
||||||
String getActorColor();
|
|
||||||
String getActorName();
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
public interface PulseStatsRow {
|
|
||||||
long getPages();
|
|
||||||
long getAnnotated();
|
|
||||||
long getTranscribed();
|
|
||||||
long getUploaded();
|
|
||||||
long getYourPages();
|
|
||||||
}
|
|
||||||
@@ -23,33 +23,4 @@ public class AsyncConfig {
|
|||||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean("auditExecutor")
|
|
||||||
public Executor auditExecutor() {
|
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
|
||||||
executor.setCorePoolSize(1);
|
|
||||||
executor.setMaxPoolSize(2);
|
|
||||||
executor.setQueueCapacity(50);
|
|
||||||
executor.setThreadNamePrefix("Audit-");
|
|
||||||
// AbortPolicy instead of CallerRunsPolicy: if CallerRunsPolicy ran the task on the
|
|
||||||
// afterCommit() callback thread, Spring's transaction synchronizations would still be
|
|
||||||
// active on that thread and SimpleJpaRepository.save() would throw IllegalStateException.
|
|
||||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
|
||||||
return executor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean("thumbnailExecutor")
|
|
||||||
public Executor thumbnailExecutor() {
|
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
|
||||||
executor.setCorePoolSize(1);
|
|
||||||
executor.setMaxPoolSize(2);
|
|
||||||
executor.setQueueCapacity(200);
|
|
||||||
executor.setThreadNamePrefix("Thumbnail-");
|
|
||||||
// CallerRunsPolicy applies back-pressure to quick-upload batches and admin backfill
|
|
||||||
// instead of dropping work (shared taskExecutor uses AbortPolicy). Safe because the
|
|
||||||
// task is dispatched via TransactionSynchronization.afterCommit, which runs on a
|
|
||||||
// post-commit callback thread without active transaction synchronization.
|
|
||||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
|
||||||
return executor;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -31,8 +31,8 @@ import java.util.Set;
|
|||||||
@DependsOn("flyway")
|
@DependsOn("flyway")
|
||||||
public class DataInitializer {
|
public class DataInitializer {
|
||||||
|
|
||||||
@Value("${app.admin.email:admin@familyarchive.local}")
|
@Value("${app.admin.username:admin}")
|
||||||
private String adminEmail;
|
private String adminUsername;
|
||||||
|
|
||||||
@Value("${app.admin.password:admin123}")
|
@Value("${app.admin.password:admin123}")
|
||||||
private String adminPassword;
|
private String adminPassword;
|
||||||
@@ -43,23 +43,26 @@ public class DataInitializer {
|
|||||||
@Bean
|
@Bean
|
||||||
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
|
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
|
||||||
return args -> {
|
return args -> {
|
||||||
if (userRepository.findByEmail(adminEmail).isEmpty()) {
|
if (userRepository.findByUsername(adminUsername).isEmpty()) {
|
||||||
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminEmail);
|
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminUsername);
|
||||||
|
|
||||||
|
// 1. Admin Gruppe erstellen
|
||||||
UserGroup adminGroup = UserGroup.builder()
|
UserGroup adminGroup = UserGroup.builder()
|
||||||
.name("Administrators")
|
.name("Administrators")
|
||||||
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
||||||
.build();
|
.build();
|
||||||
groupRepository.save(adminGroup);
|
groupRepository.save(adminGroup);
|
||||||
|
|
||||||
|
// 2. Admin User erstellen
|
||||||
AppUser admin = AppUser.builder()
|
AppUser admin = AppUser.builder()
|
||||||
.email(adminEmail)
|
.username(adminUsername)
|
||||||
.password(passwordEncoder.encode(adminPassword))
|
.password(passwordEncoder.encode(adminPassword)) // Passwort verschlüsseln!
|
||||||
|
.email("admin@familyarchive.local")
|
||||||
.groups(Set.of(adminGroup))
|
.groups(Set.of(adminGroup))
|
||||||
.build();
|
.build();
|
||||||
userRepository.save(admin);
|
userRepository.save(admin);
|
||||||
|
|
||||||
log.info("Default Admin erstellt: Email='{}'", adminEmail);
|
log.info("Default Admin erstellt: User='{}'", adminUsername);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,13 +84,16 @@ public class DataInitializer {
|
|||||||
TagRepository tagRepo,
|
TagRepository tagRepo,
|
||||||
PasswordEncoder passwordEncoder) {
|
PasswordEncoder passwordEncoder) {
|
||||||
return args -> {
|
return args -> {
|
||||||
userRepository.findByEmail(adminEmail).ifPresent(admin -> {
|
// Always reset the admin password to the configured value so a failed password-reset
|
||||||
|
// test from a previous run can never leave the account locked out.
|
||||||
|
userRepository.findByUsername(adminUsername).ifPresent(admin -> {
|
||||||
admin.setPassword(passwordEncoder.encode(adminPassword));
|
admin.setPassword(passwordEncoder.encode(adminPassword));
|
||||||
userRepository.save(admin);
|
userRepository.save(admin);
|
||||||
log.info("E2E seed: Admin-Passwort auf konfigurierten Wert zurückgesetzt.");
|
log.info("E2E seed: Admin-Passwort auf konfigurierten Wert zurückgesetzt.");
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userRepository.findByEmail("reader@familyarchive.local").isEmpty()) {
|
// 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...");
|
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
|
||||||
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
|
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
|
||||||
groupRepository.save(UserGroup.builder()
|
groupRepository.save(UserGroup.builder()
|
||||||
@@ -95,7 +101,7 @@ public class DataInitializer {
|
|||||||
.permissions(Set.of("READ_ALL"))
|
.permissions(Set.of("READ_ALL"))
|
||||||
.build()));
|
.build()));
|
||||||
userRepository.save(AppUser.builder()
|
userRepository.save(AppUser.builder()
|
||||||
.email("reader@familyarchive.local")
|
.username("reader")
|
||||||
.password(passwordEncoder.encode("reader123"))
|
.password(passwordEncoder.encode("reader123"))
|
||||||
.groups(Set.of(leserGroup))
|
.groups(Set.of(leserGroup))
|
||||||
.build());
|
.build());
|
||||||
@@ -125,6 +131,7 @@ public class DataInitializer {
|
|||||||
Tag tagUrlaub = tagRepo.save(Tag.builder().name("Urlaub").build());
|
Tag tagUrlaub = tagRepo.save(Tag.builder().name("Urlaub").build());
|
||||||
|
|
||||||
// ── Documents ────────────────────────────────────────────────────
|
// ── Documents ────────────────────────────────────────────────────
|
||||||
|
// 1. Fully transcribed letter — used by search + detail E2E tests
|
||||||
docRepo.save(Document.builder()
|
docRepo.save(Document.builder()
|
||||||
.title("Geburtsurkunde Hans Müller")
|
.title("Geburtsurkunde Hans Müller")
|
||||||
.originalFilename("geburtsurkunde_hans.pdf")
|
.originalFilename("geburtsurkunde_hans.pdf")
|
||||||
@@ -137,6 +144,7 @@ public class DataInitializer {
|
|||||||
.transcription("Hiermit wird beurkundet, dass Hans Müller am 12. April 1923 in Berlin geboren wurde.")
|
.transcription("Hiermit wird beurkundet, dass Hans Müller am 12. April 1923 in Berlin geboren wurde.")
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
// 2. Letter with multiple receivers and tags — tests multi-receiver display
|
||||||
docRepo.save(Document.builder()
|
docRepo.save(Document.builder()
|
||||||
.title("Brief aus dem Krieg")
|
.title("Brief aus dem Krieg")
|
||||||
.originalFilename("brief_krieg_1944.pdf")
|
.originalFilename("brief_krieg_1944.pdf")
|
||||||
@@ -149,6 +157,7 @@ public class DataInitializer {
|
|||||||
.transcription("Liebe Anna, ich schreibe dir aus der Front. Es geht mir den Umständen entsprechend gut.")
|
.transcription("Liebe Anna, ich schreibe dir aus der Front. Es geht mir den Umständen entsprechend gut.")
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
// 3. Postcard — no transcription, tests PLACEHOLDER status
|
||||||
docRepo.save(Document.builder()
|
docRepo.save(Document.builder()
|
||||||
.title("Urlaubspostkarte Ostsee")
|
.title("Urlaubspostkarte Ostsee")
|
||||||
.originalFilename("postkarte_1965.jpg")
|
.originalFilename("postkarte_1965.jpg")
|
||||||
@@ -160,6 +169,7 @@ public class DataInitializer {
|
|||||||
.tags(Set.of(tagUrlaub))
|
.tags(Set.of(tagUrlaub))
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
// 4. Document with no sender — tests null-sender display ("Unbekannt")
|
||||||
docRepo.save(Document.builder()
|
docRepo.save(Document.builder()
|
||||||
.title("Unbekanntes Dokument")
|
.title("Unbekanntes Dokument")
|
||||||
.originalFilename("unbekannt.pdf")
|
.originalFilename("unbekannt.pdf")
|
||||||
@@ -169,6 +179,7 @@ public class DataInitializer {
|
|||||||
.receivers(Set.of(maria))
|
.receivers(Set.of(maria))
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
// 5. Document with minimal metadata — tests sparse display
|
||||||
docRepo.save(Document.builder()
|
docRepo.save(Document.builder()
|
||||||
.title("Scan ohne Titel")
|
.title("Scan ohne Titel")
|
||||||
.originalFilename("scan_ohne_titel.pdf")
|
.originalFilename("scan_ohne_titel.pdf")
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.config;
|
|
||||||
|
|
||||||
import com.github.benmanes.caffeine.cache.Cache;
|
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
public class RateLimitInterceptor implements HandlerInterceptor {
|
|
||||||
|
|
||||||
private static final int MAX_REQUESTS_PER_MINUTE = 10;
|
|
||||||
|
|
||||||
// Caffeine cache: per-IP counter that expires 1 minute after first access.
|
|
||||||
// Bounded to 10_000 entries to prevent OOM from IP exhaustion.
|
|
||||||
private final Cache<String, AtomicInteger> requestCounts = Caffeine.newBuilder()
|
|
||||||
.expireAfterAccess(1, TimeUnit.MINUTES)
|
|
||||||
.maximumSize(10_000)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
|
||||||
throws Exception {
|
|
||||||
String ip = resolveClientIp(request);
|
|
||||||
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
|
|
||||||
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
|
|
||||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
|
||||||
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveClientIp(HttpServletRequest request) {
|
|
||||||
// Only trust X-Forwarded-For when the direct connection comes from a known
|
|
||||||
// reverse proxy (loopback or Docker private network). Trusting it unconditionally
|
|
||||||
// allows any client to spoof a different IP and bypass per-IP rate limiting.
|
|
||||||
String remoteAddr = request.getRemoteAddr();
|
|
||||||
if (isTrustedProxy(remoteAddr)) {
|
|
||||||
String forwarded = request.getHeader("X-Forwarded-For");
|
|
||||||
if (forwarded != null && !forwarded.isBlank()) {
|
|
||||||
return forwarded.split(",")[0].trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return remoteAddr;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isTrustedProxy(String ip) {
|
|
||||||
if (ip.equals("127.0.0.1") || ip.equals("::1") || ip.startsWith("10.") || ip.startsWith("192.168.")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Only RFC 1918 172.16.0.0/12 (172.16–172.31), not all of 172.x
|
|
||||||
if (ip.startsWith("172.")) {
|
|
||||||
String[] parts = ip.split("\\.");
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
try {
|
|
||||||
int second = Integer.parseInt(parts[1]);
|
|
||||||
return second >= 16 && second <= 31;
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,8 +50,6 @@ public class SecurityConfig {
|
|||||||
auth.requestMatchers("/actuator/health").permitAll();
|
auth.requestMatchers("/actuator/health").permitAll();
|
||||||
// Password reset endpoints are unauthenticated by nature
|
// Password reset endpoints are unauthenticated by nature
|
||||||
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||||
// Invite-based registration endpoints are public
|
|
||||||
auth.requestMatchers("/api/auth/invite/**", "/api/auth/register").permitAll();
|
|
||||||
// E2E test helper (only active under "e2e" profile)
|
// E2E test helper (only active under "e2e" profile)
|
||||||
auth.requestMatchers("/api/auth/reset-token-for-test").permitAll();
|
auth.requestMatchers("/api/auth/reset-token-for-test").permitAll();
|
||||||
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
|
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
|
||||||
@@ -69,7 +67,7 @@ public class SecurityConfig {
|
|||||||
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
||||||
// Erlaubt Login via Browser-Popup oder REST-Header (Authorization: Basic ...)
|
// Erlaubt Login via Browser-Popup oder REST-Header (Authorization: Basic ...)
|
||||||
.httpBasic(Customizer.withDefaults())
|
.httpBasic(Customizer.withDefaults())
|
||||||
.formLogin(form -> form.usernameParameter("email"));
|
.formLogin(Customizer.withDefaults());
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.config;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class WebConfig implements WebMvcConfigurer {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
|
||||||
registry.addInterceptor(new RateLimitInterceptor())
|
|
||||||
.addPathPatterns("/api/auth/invite/**", "/api/auth/register");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import org.raddatz.familienarchiv.security.RequirePermission;
|
|||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
import org.raddatz.familienarchiv.service.MassImportService;
|
import org.raddatz.familienarchiv.service.MassImportService;
|
||||||
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -24,7 +23,6 @@ public class AdminController {
|
|||||||
private final MassImportService massImportService;
|
private final MassImportService massImportService;
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
private final ThumbnailBackfillService thumbnailBackfillService;
|
|
||||||
|
|
||||||
@PostMapping("/trigger-import")
|
@PostMapping("/trigger-import")
|
||||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
||||||
@@ -49,15 +47,4 @@ public class AdminController {
|
|||||||
int count = documentService.backfillFileHashes();
|
int count = documentService.backfillFileHashes();
|
||||||
return ResponseEntity.ok(new BackfillResult(count));
|
return ResponseEntity.ok(new BackfillResult(count));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/generate-thumbnails")
|
|
||||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
|
|
||||||
thumbnailBackfillService.runBackfillAsync();
|
|
||||||
return ResponseEntity.accepted().body(thumbnailBackfillService.getStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/thumbnail-status")
|
|
||||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> thumbnailStatus() {
|
|
||||||
return ResponseEntity.ok(thumbnailBackfillService.getStatus());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public class AnnotationController {
|
|||||||
private UUID resolveUserId(Authentication authentication) {
|
private UUID resolveUserId(Authentication authentication) {
|
||||||
if (authentication == null || !authentication.isAuthenticated()) return null;
|
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||||
try {
|
try {
|
||||||
AppUser user = userService.findByEmail(authentication.getName());
|
AppUser user = userService.findByUsername(authentication.getName());
|
||||||
return user != null ? user.getId() : null;
|
return user != null ? user.getId() : null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Could not resolve user for annotation: {}", e.getMessage());
|
log.warn("Could not resolve user for annotation: {}", e.getMessage());
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
|
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
|
||||||
import org.raddatz.familienarchiv.dto.InvitePrefillDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.RegisterRequest;
|
|
||||||
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.model.InviteToken;
|
|
||||||
import org.raddatz.familienarchiv.service.InviteService;
|
|
||||||
import org.raddatz.familienarchiv.service.PasswordResetService;
|
import org.raddatz.familienarchiv.service.PasswordResetService;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
@@ -22,7 +18,6 @@ import lombok.RequiredArgsConstructor;
|
|||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final PasswordResetService passwordResetService;
|
private final PasswordResetService passwordResetService;
|
||||||
private final InviteService inviteService;
|
|
||||||
|
|
||||||
@Value("${app.base-url:http://localhost:3000}")
|
@Value("${app.base-url:http://localhost:3000}")
|
||||||
private String appBaseUrl;
|
private String appBaseUrl;
|
||||||
@@ -39,20 +34,4 @@ public class AuthController {
|
|||||||
passwordResetService.resetPassword(request);
|
passwordResetService.resetPassword(request);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/invite/{code}")
|
|
||||||
public InvitePrefillDTO getInvitePrefill(@PathVariable String code) {
|
|
||||||
InviteToken token = inviteService.validateCode(code);
|
|
||||||
return new InvitePrefillDTO(
|
|
||||||
token.getPrefillFirstName(),
|
|
||||||
token.getPrefillLastName(),
|
|
||||||
token.getPrefillEmail()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/register")
|
|
||||||
public ResponseEntity<AppUser> register(@Valid @RequestBody RegisterRequest request) {
|
|
||||||
AppUser user = inviteService.redeemInvite(request);
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(user);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,67 @@ public class CommentController {
|
|||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
private final UserService userService;
|
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, Permission.WRITE_ALL})
|
||||||
|
public DocumentComment postDocumentComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_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(), dto.getMentionedUserIds(), 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, Permission.WRITE_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(), dto.getMentionedUserIds(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_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(), dto.getMentionedUserIds(), author);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Block (transcription) comments ────────────────────────────────────────
|
// ─── Block (transcription) comments ────────────────────────────────────────
|
||||||
|
|
||||||
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||||
@@ -83,7 +144,7 @@ public class CommentController {
|
|||||||
private AppUser resolveUser(Authentication authentication) {
|
private AppUser resolveUser(Authentication authentication) {
|
||||||
if (authentication == null || !authentication.isAuthenticated()) return null;
|
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||||
try {
|
try {
|
||||||
return userService.findByEmail(authentication.getName());
|
return userService.findByUsername(authentication.getName());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Could not resolve user for comment: {}", e.getMessage());
|
log.warn("Could not resolve user for comment: {}", e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -14,21 +13,8 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.Max;
|
|
||||||
import jakarta.validation.constraints.Min;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.validation.annotation.Validated;
|
|
||||||
import org.raddatz.familienarchiv.dto.BatchMetadataRequest;
|
|
||||||
import org.raddatz.familienarchiv.dto.BulkEditError;
|
|
||||||
import org.raddatz.familienarchiv.dto.BulkEditResult;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -38,16 +24,12 @@ import org.raddatz.familienarchiv.dto.DocumentSort;
|
|||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.TrainingLabel;
|
import org.raddatz.familienarchiv.model.TrainingLabel;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
import org.raddatz.familienarchiv.service.FileService;
|
import org.raddatz.familienarchiv.service.FileService;
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -75,13 +57,11 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
@RequestMapping("/api/documents")
|
@RequestMapping("/api/documents")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Validated
|
|
||||||
public class DocumentController {
|
public class DocumentController {
|
||||||
|
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final UserService userService;
|
|
||||||
|
|
||||||
// --- DOWNLOAD ---
|
// --- DOWNLOAD ---
|
||||||
@GetMapping("/{id}/file")
|
@GetMapping("/{id}/file")
|
||||||
@@ -108,31 +88,6 @@ public class DocumentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- THUMBNAIL ---
|
|
||||||
@GetMapping("/{id}/thumbnail")
|
|
||||||
public ResponseEntity<InputStreamResource> getDocumentThumbnail(@PathVariable UUID id) {
|
|
||||||
Document doc = documentService.getDocumentById(id);
|
|
||||||
|
|
||||||
if (doc.getThumbnailKey() == null) {
|
|
||||||
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "No thumbnail for document: " + id);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
FileService.S3FileDownload download = fileService.downloadFile(doc.getThumbnailKey());
|
|
||||||
return ResponseEntity.ok()
|
|
||||||
.contentType(MediaType.IMAGE_JPEG)
|
|
||||||
// `private` (not `public`) prevents shared caches from serving one user's
|
|
||||||
// thumbnail to another (CWE-525). `immutable` is safe because the URL
|
|
||||||
// carries a ?v=<thumbnailGeneratedAt> cache-buster that changes whenever
|
|
||||||
// the underlying file is replaced.
|
|
||||||
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable")
|
|
||||||
.body(download.resource());
|
|
||||||
} catch (FileService.StorageFileNotFoundException e) {
|
|
||||||
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND,
|
|
||||||
"Thumbnail missing in storage: " + doc.getThumbnailKey());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- METADATA ---
|
// --- METADATA ---
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Document getDocument(@PathVariable UUID id) {
|
public Document getDocument(@PathVariable UUID id) {
|
||||||
@@ -156,10 +111,9 @@ public class DocumentController {
|
|||||||
public Document updateDocument(
|
public Document updateDocument(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@ModelAttribute DocumentUpdateDTO dto,
|
@ModelAttribute DocumentUpdateDTO dto,
|
||||||
@RequestPart(value = "file", required = false) MultipartFile file,
|
@RequestPart(value = "file", required = false) MultipartFile file) {
|
||||||
Authentication authentication) {
|
|
||||||
try {
|
try {
|
||||||
return documentService.updateDocument(id, dto, file, requireUserId(authentication));
|
return documentService.updateDocument(id, dto, file);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
|
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -174,35 +128,18 @@ public class DocumentController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ATTACH FILE ---
|
// --- QUICK UPLOAD ---
|
||||||
|
|
||||||
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||||
"application/pdf", "image/jpeg", "image/png", "image/tiff");
|
"application/pdf", "image/jpeg", "image/png", "image/tiff");
|
||||||
|
|
||||||
@PostMapping(value = "/{id}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public Document attachFile(
|
|
||||||
@PathVariable UUID id,
|
|
||||||
@RequestPart("file") MultipartFile file,
|
|
||||||
Authentication authentication) {
|
|
||||||
String contentType = file.getContentType();
|
|
||||||
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type: " + contentType);
|
|
||||||
}
|
|
||||||
return documentService.attachFile(id, file, requireUserId(authentication));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- QUICK UPLOAD ---
|
|
||||||
|
|
||||||
public record UploadError(String filename, String code) {}
|
public record UploadError(String filename, String code) {}
|
||||||
public record QuickUploadResult(List<Document> created, List<Document> updated, List<UploadError> errors) {}
|
public record QuickUploadResult(List<Document> created, List<Document> updated, List<UploadError> errors) {}
|
||||||
|
|
||||||
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public QuickUploadResult quickUpload(
|
public QuickUploadResult quickUpload(
|
||||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
@RequestPart(value = "files", required = false) List<MultipartFile> files) {
|
||||||
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
|
|
||||||
Authentication authentication) {
|
|
||||||
List<Document> created = new ArrayList<>();
|
List<Document> created = new ArrayList<>();
|
||||||
List<Document> updated = new ArrayList<>();
|
List<Document> updated = new ArrayList<>();
|
||||||
List<UploadError> errors = new ArrayList<>();
|
List<UploadError> errors = new ArrayList<>();
|
||||||
@@ -211,21 +148,13 @@ public class DocumentController {
|
|||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
documentService.validateBatch(files.size(), metadata);
|
for (MultipartFile file : files) {
|
||||||
|
|
||||||
UUID actorId = requireUserId(authentication);
|
|
||||||
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
|
|
||||||
|
|
||||||
for (int i = 0; i < files.size(); i++) {
|
|
||||||
MultipartFile file = files.get(i);
|
|
||||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
DocumentService.StoreResult result = metadata != null
|
DocumentService.StoreResult result = documentService.storeDocument(file);
|
||||||
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
|
|
||||||
: documentService.storeDocument(file, actorId);
|
|
||||||
if (result.isNew()) {
|
if (result.isNew()) {
|
||||||
created.add(result.document());
|
created.add(result.document());
|
||||||
} else {
|
} else {
|
||||||
@@ -237,129 +166,33 @@ public class DocumentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
|
|
||||||
actorId, files.size(), totalBytes, metadata != null,
|
|
||||||
created.size(), updated.size(), errors.size());
|
|
||||||
|
|
||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- BULK EDIT ---
|
|
||||||
|
|
||||||
private static final int BULK_EDIT_MAX_IDS = 500;
|
|
||||||
/** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered
|
|
||||||
* call from materialising the entire {@code documents} table into JSON.
|
|
||||||
* Generous enough for real-world "Alle X editieren" against the family
|
|
||||||
* archive's bounded scale (~1500 docs today, expected growth to ~5k). */
|
|
||||||
private static final int BULK_EDIT_FILTER_MAX_IDS = 5000;
|
|
||||||
|
|
||||||
@PatchMapping("/bulk")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public BulkEditResult patchBulk(
|
|
||||||
@RequestBody @Valid DocumentBulkEditDTO dto,
|
|
||||||
Authentication authentication) {
|
|
||||||
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
|
|
||||||
}
|
|
||||||
if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
|
||||||
"Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
|
|
||||||
}
|
|
||||||
|
|
||||||
UUID actorId = requireUserId(authentication);
|
|
||||||
int updated = 0;
|
|
||||||
List<BulkEditError> errors = new ArrayList<>();
|
|
||||||
|
|
||||||
// Dedupe duplicate document IDs while preserving submission order. A
|
|
||||||
// double-click on "Alle X editieren" would otherwise hit each document
|
|
||||||
// twice and inflate the `updated` count returned to the user.
|
|
||||||
LinkedHashSet<UUID> uniqueIds = new LinkedHashSet<>(dto.getDocumentIds());
|
|
||||||
|
|
||||||
for (UUID id : uniqueIds) {
|
|
||||||
try {
|
|
||||||
documentService.applyBulkEditToDocument(id, dto, actorId);
|
|
||||||
updated++;
|
|
||||||
} catch (DomainException e) {
|
|
||||||
errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage())));
|
|
||||||
} catch (Exception e) {
|
|
||||||
errors.add(new BulkEditError(id, "Internal error"));
|
|
||||||
log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}",
|
|
||||||
actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size());
|
|
||||||
|
|
||||||
return new BulkEditResult(updated, errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CRLF strip for any log line interpolating a free-form string (e.g.
|
|
||||||
* {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */
|
|
||||||
private static String sanitizeForLog(String s) {
|
|
||||||
return s == null ? null : s.replaceAll("[\\r\\n]", "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/ids")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public List<UUID> getDocumentIds(
|
|
||||||
@RequestParam(required = false) String q,
|
|
||||||
@RequestParam(required = false) LocalDate from,
|
|
||||||
@RequestParam(required = false) LocalDate to,
|
|
||||||
@RequestParam(required = false) UUID senderId,
|
|
||||||
@RequestParam(required = false) UUID receiverId,
|
|
||||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
|
||||||
@RequestParam(required = false) String tagQ,
|
|
||||||
@RequestParam(required = false) DocumentStatus status,
|
|
||||||
@RequestParam(required = false) String tagOp,
|
|
||||||
Authentication authentication) {
|
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
|
||||||
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
|
|
||||||
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
|
||||||
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
|
|
||||||
}
|
|
||||||
UUID actorId = requireUserId(authentication);
|
|
||||||
log.info("documentIds actor={} matched={}", actorId, ids.size());
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
|
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
public List<DocumentBatchSummary> batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) {
|
|
||||||
if (request == null || request.ids() == null || request.ids().isEmpty()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
|
|
||||||
}
|
|
||||||
if (request.ids().size() > BULK_EDIT_MAX_IDS) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
|
||||||
"Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size());
|
|
||||||
}
|
|
||||||
UUID actorId = requireUserId(authentication);
|
|
||||||
log.info("batchMetadata actor={} ids={}", actorId, request.ids().size());
|
|
||||||
return documentService.batchMetadata(request.ids());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/incomplete-count")
|
@GetMapping("/incomplete-count")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public Map<String, Long> getIncompleteCount() {
|
public Map<String, Long> getIncompleteCount() {
|
||||||
return Map.of("count", documentService.getIncompleteCount());
|
return Map.of("count", documentService.getIncompleteCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/incomplete")
|
@GetMapping("/incomplete")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public List<IncompleteDocumentDTO> getIncomplete(
|
public List<IncompleteDocumentDTO> getIncomplete(
|
||||||
@Parameter(description = "Maximum number of results (server caps at 200)")
|
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
|
||||||
@RequestParam(defaultValue = "50") int size) {
|
return documentService.findIncompleteDocuments(size);
|
||||||
return documentService.findIncompleteDocuments(Math.min(size, 200));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/incomplete/next")
|
@GetMapping("/incomplete/next")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||||
return documentService.findNextIncompleteDocument(excludeId)
|
return documentService.findNextIncompleteDocument(excludeId)
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElse(ResponseEntity.noContent().build());
|
.orElse(ResponseEntity.noContent().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recent-activity")
|
||||||
|
public ResponseEntity<List<Document>> getRecentActivity(
|
||||||
|
@RequestParam(defaultValue = "5") int size) {
|
||||||
|
return ResponseEntity.ok(documentService.getRecentActivity(size));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<DocumentSearchResult> search(
|
public ResponseEntity<DocumentSearchResult> search(
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
@@ -371,21 +204,12 @@ public class DocumentController {
|
|||||||
@RequestParam(required = false) String tagQ,
|
@RequestParam(required = false) String tagQ,
|
||||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir) {
|
||||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
|
|
||||||
// @Max on page guards against overflow when pageable.getOffset() is computed
|
|
||||||
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
|
|
||||||
// Hibernate cheerfully turns into an invalid SQL OFFSET.
|
|
||||||
@Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
|
|
||||||
@Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
|
|
||||||
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||||
}
|
}
|
||||||
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
List<Document> results = documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir);
|
||||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
return ResponseEntity.ok(DocumentSearchResult.of(results));
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
|
||||||
Pageable pageable = PageRequest.of(page, size);
|
|
||||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TRAINING LABELS ---
|
// --- TRAINING LABELS ---
|
||||||
@@ -434,8 +258,4 @@ public class DocumentController {
|
|||||||
Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate");
|
Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate");
|
||||||
return documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
|
return documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
private UUID requireUserId(Authentication authentication) {
|
|
||||||
return SecurityUtils.requireUserId(authentication, userService);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.model.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
|
||||||
import org.raddatz.familienarchiv.service.GeschichteService;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PatchMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
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.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/geschichten")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class GeschichteController {
|
|
||||||
|
|
||||||
private final GeschichteService geschichteService;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
public List<Geschichte> list(
|
|
||||||
@RequestParam(required = false) GeschichteStatus status,
|
|
||||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
|
||||||
@RequestParam(required = false) UUID documentId,
|
|
||||||
@RequestParam(required = false, defaultValue = "50") int limit) {
|
|
||||||
return geschichteService.list(
|
|
||||||
status,
|
|
||||||
personIds == null ? List.of() : personIds,
|
|
||||||
documentId,
|
|
||||||
limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
public Geschichte getById(@PathVariable UUID id) {
|
|
||||||
return geschichteService.getById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
|
||||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
|
||||||
Geschichte created = geschichteService.create(dto);
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PatchMapping("/{id}")
|
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
|
||||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
|
||||||
return geschichteService.update(id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
|
||||||
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
|
||||||
geschichteService.delete(id);
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import jakarta.validation.ConstraintViolationException;
|
|||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
@@ -48,12 +47,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
|
||||||
public ResponseEntity<ErrorResponse> handleMessageNotReadable(HttpMessageNotReadableException ex) {
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExceptionHandler(ResponseStatusException.class)
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||||
return ResponseEntity.status(ex.getStatusCode())
|
return ResponseEntity.status(ex.getStatusCode())
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.raddatz.familienarchiv.dto.CreateInviteRequest;
|
|
||||||
import org.raddatz.familienarchiv.dto.InviteListItemDTO;
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
|
||||||
import org.raddatz.familienarchiv.service.InviteService;
|
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/invites")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class InviteController {
|
|
||||||
|
|
||||||
private final InviteService inviteService;
|
|
||||||
private final UserService userService;
|
|
||||||
|
|
||||||
@Value("${app.base-url:http://localhost:3000}")
|
|
||||||
private String appBaseUrl;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
|
||||||
public List<InviteListItemDTO> listInvites(
|
|
||||||
@RequestParam(value = "status", defaultValue = "active") String status) {
|
|
||||||
boolean activeOnly = !"all".equalsIgnoreCase(status);
|
|
||||||
return inviteService.listInvites(activeOnly, appBaseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
|
||||||
public ResponseEntity<InviteListItemDTO> createInvite(
|
|
||||||
@RequestBody CreateInviteRequest request,
|
|
||||||
@AuthenticationPrincipal UserDetails principal) {
|
|
||||||
AppUser creator = userService.findByEmail(principal.getUsername());
|
|
||||||
InviteListItemDTO created = inviteService.toListItemDTO(
|
|
||||||
inviteService.createInvite(request, creator), appBaseUrl);
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
|
||||||
public ResponseEntity<Void> revokeInvite(@PathVariable UUID id) {
|
|
||||||
inviteService.revokeInvite(id);
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -100,6 +100,6 @@ public class NotificationController {
|
|||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private AppUser resolveUser(Authentication authentication) {
|
private AppUser resolveUser(Authentication authentication) {
|
||||||
return userService.findByEmail(authentication.getName());
|
return userService.findByUsername(authentication.getName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.dto.BatchOcrDTO;
|
import org.raddatz.familienarchiv.dto.BatchOcrDTO;
|
||||||
import org.raddatz.familienarchiv.dto.OcrStatusDTO;
|
import org.raddatz.familienarchiv.dto.OcrStatusDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TrainingHistoryResponse;
|
|
||||||
import org.raddatz.familienarchiv.dto.TrainingInfoResponse;
|
|
||||||
import org.raddatz.familienarchiv.dto.TriggerOcrDTO;
|
import org.raddatz.familienarchiv.dto.TriggerOcrDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TriggerSenderTrainingDTO;
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.OcrJob;
|
import org.raddatz.familienarchiv.model.OcrJob;
|
||||||
import org.raddatz.familienarchiv.model.OcrTrainingRun;
|
import org.raddatz.familienarchiv.model.OcrTrainingRun;
|
||||||
@@ -18,7 +15,6 @@ import org.raddatz.familienarchiv.service.OcrProgressService;
|
|||||||
import org.raddatz.familienarchiv.service.OcrService;
|
import org.raddatz.familienarchiv.service.OcrService;
|
||||||
import org.raddatz.familienarchiv.service.OcrTrainingService;
|
import org.raddatz.familienarchiv.service.OcrTrainingService;
|
||||||
import org.raddatz.familienarchiv.service.SegmentationTrainingExportService;
|
import org.raddatz.familienarchiv.service.SegmentationTrainingExportService;
|
||||||
import org.raddatz.familienarchiv.service.SenderModelService;
|
|
||||||
import org.raddatz.familienarchiv.service.TrainingDataExportService;
|
import org.raddatz.familienarchiv.service.TrainingDataExportService;
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -46,7 +42,6 @@ public class OcrController {
|
|||||||
private final TrainingDataExportService trainingDataExportService;
|
private final TrainingDataExportService trainingDataExportService;
|
||||||
private final SegmentationTrainingExportService segmentationTrainingExportService;
|
private final SegmentationTrainingExportService segmentationTrainingExportService;
|
||||||
private final OcrTrainingService ocrTrainingService;
|
private final OcrTrainingService ocrTrainingService;
|
||||||
private final SenderModelService senderModelService;
|
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/ocr")
|
@PostMapping("/api/documents/{documentId}/ocr")
|
||||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||||
@@ -135,33 +130,14 @@ public class OcrController {
|
|||||||
|
|
||||||
@GetMapping("/api/ocr/training-info")
|
@GetMapping("/api/ocr/training-info")
|
||||||
@RequirePermission(Permission.ADMIN)
|
@RequirePermission(Permission.ADMIN)
|
||||||
public TrainingInfoResponse getTrainingInfo() {
|
public OcrTrainingService.TrainingInfoResponse getTrainingInfo() {
|
||||||
return ocrTrainingService.getTrainingInfo();
|
return ocrTrainingService.getTrainingInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/ocr/training-info/global")
|
|
||||||
@RequirePermission(Permission.ADMIN)
|
|
||||||
public TrainingHistoryResponse getGlobalTrainingHistory() {
|
|
||||||
return ocrTrainingService.getGlobalTrainingHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/api/ocr/training-info/{personId}")
|
|
||||||
@RequirePermission(Permission.ADMIN)
|
|
||||||
public TrainingHistoryResponse getSenderTrainingHistory(@PathVariable UUID personId) {
|
|
||||||
return ocrTrainingService.getSenderTrainingHistory(personId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/api/ocr/train-sender")
|
|
||||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
|
||||||
@RequirePermission(Permission.ADMIN)
|
|
||||||
public OcrTrainingRun triggerSenderTraining(@Valid @RequestBody TriggerSenderTrainingDTO dto) {
|
|
||||||
return senderModelService.triggerManualSenderTraining(dto.personId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID resolveUserId(Authentication authentication) {
|
private UUID resolveUserId(Authentication authentication) {
|
||||||
if (authentication == null || !authentication.isAuthenticated()) return null;
|
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||||
try {
|
try {
|
||||||
AppUser user = userService.findByEmail(authentication.getName());
|
AppUser user = userService.findByUsername(authentication.getName());
|
||||||
return user != null ? user.getId() : null;
|
return user != null ? user.getId() : null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to resolve user ID for authentication: {}", authentication.getName(), e);
|
log.warn("Failed to resolve user ID for authentication: {}", authentication.getName(), e);
|
||||||
|
|||||||
@@ -34,13 +34,11 @@ public class PersonController {
|
|||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||||
return ResponseEntity.ok(personService.findAll(q));
|
return ResponseEntity.ok(personService.findAll(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
public Person getPerson(@PathVariable UUID id) {
|
public Person getPerson(@PathVariable UUID id) {
|
||||||
return personService.getById(id);
|
return personService.getById(id);
|
||||||
}
|
}
|
||||||
@@ -65,33 +63,27 @@ public class PersonController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
validatePersonNames(dto);
|
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
|| 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());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
|
||||||
return ResponseEntity.ok(personService.createPerson(dto));
|
return ResponseEntity.ok(personService.createPerson(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
validatePersonNames(dto);
|
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
|| 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());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
|
||||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validatePersonNames(PersonUpdateDTO dto) {
|
|
||||||
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
|
|
||||||
}
|
|
||||||
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
|
|
||||||
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/merge")
|
@PostMapping("/{id}/merge")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.MergeTagDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.TagTreeNodeDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.TagUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.TagService;
|
import org.raddatz.familienarchiv.service.TagService;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
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.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import jakarta.validation.Valid;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
@@ -37,8 +31,8 @@ public class TagController {
|
|||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_TAG)
|
@RequirePermission(Permission.ADMIN_TAG)
|
||||||
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody TagUpdateDTO dto) {
|
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody Map<String, String> payload) {
|
||||||
return ResponseEntity.ok(tagService.update(id, dto));
|
return ResponseEntity.ok(tagService.update(id, payload.get("name")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@@ -52,22 +46,4 @@ public class TagController {
|
|||||||
public List<Tag> searchTags(@RequestParam(defaultValue = "") String query) {
|
public List<Tag> searchTags(@RequestParam(defaultValue = "") String query) {
|
||||||
return tagService.search(query);
|
return tagService.search(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/tree")
|
|
||||||
public List<TagTreeNodeDTO> getTagTree() {
|
|
||||||
return tagService.getTagTree();
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/merge")
|
|
||||||
@RequirePermission(Permission.ADMIN_TAG)
|
|
||||||
public ResponseEntity<Tag> mergeTag(@PathVariable UUID id, @Valid @RequestBody MergeTagDTO dto) {
|
|
||||||
return ResponseEntity.ok(tagService.mergeTags(id, dto.targetId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}/subtree")
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
@RequirePermission(Permission.ADMIN_TAG)
|
|
||||||
public void deleteSubtree(@PathVariable UUID id) {
|
|
||||||
tagService.deleteWithDescendants(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||||
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
|
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
|
||||||
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
|
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
|
||||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -46,7 +46,7 @@ public class TranscriptionBlockController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public TranscriptionBlock createBlock(
|
public TranscriptionBlock createBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
@RequestBody CreateTranscriptionBlockDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
UUID userId = requireUserId(authentication);
|
UUID userId = requireUserId(authentication);
|
||||||
return transcriptionService.createBlock(documentId, dto, userId);
|
return transcriptionService.createBlock(documentId, dto, userId);
|
||||||
@@ -57,7 +57,7 @@ public class TranscriptionBlockController {
|
|||||||
public TranscriptionBlock updateBlock(
|
public TranscriptionBlock updateBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId,
|
@PathVariable UUID blockId,
|
||||||
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
|
@RequestBody UpdateTranscriptionBlockDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
UUID userId = requireUserId(authentication);
|
UUID userId = requireUserId(authentication);
|
||||||
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
||||||
@@ -85,19 +85,8 @@ public class TranscriptionBlockController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public TranscriptionBlock reviewBlock(
|
public TranscriptionBlock reviewBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId,
|
@PathVariable UUID blockId) {
|
||||||
Authentication authentication) {
|
return transcriptionService.reviewBlock(documentId, blockId);
|
||||||
UUID userId = requireUserId(authentication);
|
|
||||||
return transcriptionService.reviewBlock(documentId, blockId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/review-all")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public List<TranscriptionBlock> markAllBlocksReviewed(
|
|
||||||
@PathVariable UUID documentId,
|
|
||||||
Authentication authentication) {
|
|
||||||
UUID userId = requireUserId(authentication);
|
|
||||||
return transcriptionService.markAllBlocksReviewed(documentId, userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{blockId}/history")
|
@GetMapping("/{blockId}/history")
|
||||||
@@ -109,6 +98,13 @@ public class TranscriptionBlockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private UUID requireUserId(Authentication authentication) {
|
private UUID requireUserId(Authentication authentication) {
|
||||||
return SecurityUtils.requireUserId(authentication, userService);
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
|
throw DomainException.unauthorized("Authentication required");
|
||||||
|
}
|
||||||
|
AppUser user = userService.findByUsername(authentication.getName());
|
||||||
|
if (user == null) {
|
||||||
|
throw DomainException.unauthorized("User not found");
|
||||||
|
}
|
||||||
|
return user.getId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
|
||||||
import org.raddatz.familienarchiv.service.TranscriptionQueueService;
|
|
||||||
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.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serves the three Mission Control Strip columns for the dashboard.
|
|
||||||
* All endpoints require READ_ALL — same guard as the rest of the archive.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/transcription")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
public class TranscriptionQueueController {
|
|
||||||
|
|
||||||
private final TranscriptionQueueService transcriptionQueueService;
|
|
||||||
|
|
||||||
@GetMapping("/segmentation-queue")
|
|
||||||
public ResponseEntity<List<TranscriptionQueueItemDTO>> getSegmentationQueue() {
|
|
||||||
return ResponseEntity.ok(transcriptionQueueService.getSegmentationQueue());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/transcription-queue")
|
|
||||||
public ResponseEntity<List<TranscriptionQueueItemDTO>> getTranscriptionQueue() {
|
|
||||||
return ResponseEntity.ok(transcriptionQueueService.getTranscriptionQueue());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/ready-to-read")
|
|
||||||
public ResponseEntity<List<TranscriptionQueueItemDTO>> getReadyToRead() {
|
|
||||||
return ResponseEntity.ok(transcriptionQueueService.getReadyToReadQueue());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/weekly-stats")
|
|
||||||
public ResponseEntity<TranscriptionWeeklyStatsDTO> getWeeklyStats() {
|
|
||||||
return ResponseEntity.ok(transcriptionQueueService.getWeeklyStats());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
@@ -39,7 +38,7 @@ public class UserController {
|
|||||||
if (authentication == null || !authentication.isAuthenticated()) {
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
}
|
}
|
||||||
AppUser user = userService.findByEmail(authentication.getName());
|
AppUser user = userService.findByUsername(authentication.getName());
|
||||||
user.setPassword(null);
|
user.setPassword(null);
|
||||||
return ResponseEntity.ok(user);
|
return ResponseEntity.ok(user);
|
||||||
}
|
}
|
||||||
@@ -47,7 +46,7 @@ public class UserController {
|
|||||||
@PutMapping("users/me")
|
@PutMapping("users/me")
|
||||||
public ResponseEntity<AppUser> updateProfile(Authentication authentication,
|
public ResponseEntity<AppUser> updateProfile(Authentication authentication,
|
||||||
@RequestBody UpdateProfileDTO dto) {
|
@RequestBody UpdateProfileDTO dto) {
|
||||||
AppUser current = userService.findByEmail(authentication.getName());
|
AppUser current = userService.findByUsername(authentication.getName());
|
||||||
AppUser updated = userService.updateProfile(current.getId(), dto);
|
AppUser updated = userService.updateProfile(current.getId(), dto);
|
||||||
updated.setPassword(null);
|
updated.setPassword(null);
|
||||||
return ResponseEntity.ok(updated);
|
return ResponseEntity.ok(updated);
|
||||||
@@ -57,7 +56,7 @@ public class UserController {
|
|||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
public void changePassword(Authentication authentication,
|
public void changePassword(Authentication authentication,
|
||||||
@RequestBody ChangePasswordDTO dto) {
|
@RequestBody ChangePasswordDTO dto) {
|
||||||
AppUser current = userService.findByEmail(authentication.getName());
|
AppUser current = userService.findByUsername(authentication.getName());
|
||||||
userService.changePassword(current.getId(), dto);
|
userService.changePassword(current.getId(), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,31 +77,24 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/users")
|
@PostMapping("/users")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> createUser(Authentication authentication,
|
public ResponseEntity<AppUser> createUser(@RequestBody CreateUserRequest request) {
|
||||||
@Valid @RequestBody CreateUserRequest request) {
|
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
||||||
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/users/{id}")
|
@PutMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
|
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
|
||||||
@PathVariable UUID id,
|
|
||||||
@RequestBody AdminUpdateUserRequest dto) {
|
@RequestBody AdminUpdateUserRequest dto) {
|
||||||
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto);
|
AppUser updated = userService.adminUpdateUser(id, dto);
|
||||||
updated.setPassword(null);
|
updated.setPassword(null);
|
||||||
return ResponseEntity.ok(updated);
|
return ResponseEntity.ok(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/users/{id}")
|
@DeleteMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<Void> deleteUser(Authentication authentication,
|
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
|
||||||
@PathVariable UUID id) {
|
userService.deleteUser(id);
|
||||||
userService.deleteUser(actorId(authentication), id);
|
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private UUID actorId(Authentication auth) {
|
|
||||||
return userService.findByEmail(auth.getName()).getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.annotation.Nullable;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record ActivityFeedItemDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) AuditKind kind,
|
|
||||||
@Nullable ActivityActorDTO actor,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youParticipated,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count,
|
|
||||||
@Nullable OffsetDateTime happenedAtUntil,
|
|
||||||
@Nullable
|
|
||||||
@Schema(
|
|
||||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
|
||||||
description = "Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
|
||||||
)
|
|
||||||
UUID commentId,
|
|
||||||
@Nullable
|
|
||||||
@Schema(
|
|
||||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
|
||||||
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
|
||||||
)
|
|
||||||
UUID annotationId
|
|
||||||
) {}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
|
||||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/dashboard")
|
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DashboardController {
|
|
||||||
|
|
||||||
private final DashboardService dashboardService;
|
|
||||||
private final UserService userService;
|
|
||||||
|
|
||||||
@GetMapping("/resume")
|
|
||||||
public DashboardResumeDTO getResume(Authentication authentication) {
|
|
||||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
|
||||||
return dashboardService.getResume(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/pulse")
|
|
||||||
public DashboardPulseDTO getPulse(Authentication authentication) {
|
|
||||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
|
||||||
return dashboardService.getPulse(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/activity")
|
|
||||||
public List<ActivityFeedItemDTO> getActivity(
|
|
||||||
Authentication authentication,
|
|
||||||
@RequestParam(defaultValue = "7") int limit,
|
|
||||||
@Parameter(description = "Filter by audit kinds; omit for all rollup-eligible kinds",
|
|
||||||
array = @ArraySchema(schema = @Schema(implementation = AuditKind.class)))
|
|
||||||
@RequestParam(required = false) Set<AuditKind> kinds) {
|
|
||||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
|
||||||
Set<AuditKind> effectiveKinds = (kinds == null || kinds.isEmpty()) ? AuditKind.ROLLUP_ELIGIBLE : kinds;
|
|
||||||
return dashboardService.getActivity(userId, Math.min(limit, 40), effectiveKinds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record DashboardPulseDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pages,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotated,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int transcribed,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int uploaded,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int yourPages,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> contributors
|
|
||||||
) {}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.annotation.Nullable;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record DashboardResumeDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String caption,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String excerpt,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int totalBlocks,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pct,
|
|
||||||
@Nullable String thumbnailUrl,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> collaborators
|
|
||||||
) {}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
|
||||||
import org.raddatz.familienarchiv.audit.PulseStatsRow;
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
|
||||||
import org.raddatz.familienarchiv.service.CommentService;
|
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
|
||||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.DayOfWeek;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.time.temporal.TemporalAdjusters;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class DashboardService {
|
|
||||||
|
|
||||||
private final AuditLogQueryService auditLogQueryService;
|
|
||||||
private final DocumentService documentService;
|
|
||||||
private final TranscriptionService transcriptionService;
|
|
||||||
private final UserService userService;
|
|
||||||
private final CommentService commentService;
|
|
||||||
|
|
||||||
public DashboardResumeDTO getResume(UUID userId) {
|
|
||||||
Optional<UUID> docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId);
|
|
||||||
if (docIdOpt.isEmpty()) return null;
|
|
||||||
|
|
||||||
UUID docId = docIdOpt.get();
|
|
||||||
Document doc;
|
|
||||||
try {
|
|
||||||
doc = documentService.getDocumentById(docId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Resume: document {} not found for user {}", docId, userId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<TranscriptionBlock> blocks = transcriptionService.listBlocks(docId);
|
|
||||||
String excerpt = blocks.stream()
|
|
||||||
.filter(b -> b.getText() != null && !b.getText().isBlank())
|
|
||||||
.min(Comparator.comparingInt(TranscriptionBlock::getSortOrder))
|
|
||||||
.map(b -> b.getText().length() > 200 ? b.getText().substring(0, 200) + "…" : b.getText())
|
|
||||||
.orElse("");
|
|
||||||
|
|
||||||
int totalBlocks = blocks.size();
|
|
||||||
long reviewedBlocks = blocks.stream().filter(TranscriptionBlock::isReviewed).count();
|
|
||||||
int pct = totalBlocks > 0 ? (int) (reviewedBlocks * 100L / totalBlocks) : 0;
|
|
||||||
|
|
||||||
String caption = buildCaption(doc);
|
|
||||||
|
|
||||||
List<UUID> collaboratorIds = blocks.stream()
|
|
||||||
.map(TranscriptionBlock::getUpdatedBy)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.distinct()
|
|
||||||
.limit(5)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
List<ActivityActorDTO> collaborators = collaboratorIds.stream()
|
|
||||||
.map(uid -> {
|
|
||||||
try {
|
|
||||||
AppUser u = userService.getById(uid);
|
|
||||||
return toActorDTO(u);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
|
|
||||||
totalBlocks, pct, doc.getThumbnailUrl(), collaborators);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DashboardPulseDTO getPulse(UUID userId) {
|
|
||||||
OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC)
|
|
||||||
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
|
||||||
.withHour(0).withMinute(0).withSecond(0).withNano(0);
|
|
||||||
|
|
||||||
PulseStatsRow stats = auditLogQueryService.getPulseStats(weekStart, userId);
|
|
||||||
|
|
||||||
List<ActivityFeedRow> feed = auditLogQueryService.findActivityFeed(userId, 50);
|
|
||||||
List<ActivityActorDTO> contributors = feed.stream()
|
|
||||||
.filter(r -> r.getActorId() != null)
|
|
||||||
.map(r -> new ActivityActorDTO(r.getActorInitials(), r.getActorColor(), r.getActorName()))
|
|
||||||
.filter(a -> !a.initials().isBlank())
|
|
||||||
.distinct()
|
|
||||||
.limit(6)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return new DashboardPulseDTO(
|
|
||||||
(int) stats.getPages(),
|
|
||||||
(int) stats.getAnnotated(),
|
|
||||||
(int) stats.getTranscribed(),
|
|
||||||
(int) stats.getUploaded(),
|
|
||||||
(int) stats.getYourPages(),
|
|
||||||
contributors
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit, Set<AuditKind> kinds) {
|
|
||||||
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit, kinds);
|
|
||||||
|
|
||||||
List<UUID> docIds = rows.stream()
|
|
||||||
.map(ActivityFeedRow::getDocumentId)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.distinct()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
Map<UUID, String> titleCache = new HashMap<>();
|
|
||||||
try {
|
|
||||||
documentService.getDocumentsByIds(docIds)
|
|
||||||
.forEach(d -> titleCache.put(d.getId(), d.getTitle()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Activity: failed to bulk-load document titles", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<UUID> commentIds = rows.stream()
|
|
||||||
.map(ActivityFeedRow::getCommentId)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.distinct()
|
|
||||||
.toList();
|
|
||||||
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
|
|
||||||
? Map.of()
|
|
||||||
: commentService.findAnnotationIdsByIds(commentIds);
|
|
||||||
|
|
||||||
return rows.stream().map(row -> {
|
|
||||||
ActivityActorDTO actor = row.getActorId() != null
|
|
||||||
? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName())
|
|
||||||
: null;
|
|
||||||
String docTitle = titleCache.getOrDefault(row.getDocumentId(), "");
|
|
||||||
OffsetDateTime happenedAtUntil = row.getHappenedAtUntil() != null
|
|
||||||
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
|
|
||||||
: null;
|
|
||||||
UUID commentId = row.getCommentId();
|
|
||||||
UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null;
|
|
||||||
return new ActivityFeedItemDTO(
|
|
||||||
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
|
||||||
actor,
|
|
||||||
row.getDocumentId(),
|
|
||||||
docTitle,
|
|
||||||
row.getHappenedAt().atOffset(ZoneOffset.UTC),
|
|
||||||
row.isYouMentioned(),
|
|
||||||
row.isYouParticipated(),
|
|
||||||
row.getCount(),
|
|
||||||
happenedAtUntil,
|
|
||||||
commentId,
|
|
||||||
annotationId
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildCaption(Document doc) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
if (doc.getSender() != null) sb.append(personName(doc.getSender()));
|
|
||||||
if (!doc.getReceivers().isEmpty()) {
|
|
||||||
String receivers = doc.getReceivers().stream()
|
|
||||||
.map(this::personName).collect(Collectors.joining(", "));
|
|
||||||
if (!sb.isEmpty()) sb.append(" an ");
|
|
||||||
sb.append(receivers);
|
|
||||||
}
|
|
||||||
if (doc.getDocumentDate() != null) {
|
|
||||||
if (!sb.isEmpty()) sb.append(" · ");
|
|
||||||
sb.append(doc.getDocumentDate());
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String personName(Person p) {
|
|
||||||
if (p == null) return "";
|
|
||||||
if (p.getFirstName() != null && p.getLastName() != null) return p.getFirstName() + " " + p.getLastName();
|
|
||||||
if (p.getFirstName() != null) return p.getFirstName();
|
|
||||||
if (p.getLastName() != null) return p.getLastName();
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private ActivityActorDTO toActorDTO(AppUser u) {
|
|
||||||
String initials = "";
|
|
||||||
if (u.getFirstName() != null && !u.getFirstName().isBlank())
|
|
||||||
initials += u.getFirstName().charAt(0);
|
|
||||||
if (u.getLastName() != null && !u.getLastName().isBlank())
|
|
||||||
initials += u.getLastName().charAt(0);
|
|
||||||
if (initials.isBlank() && u.getEmail() != null)
|
|
||||||
initials = u.getEmail().substring(0, 1).toUpperCase();
|
|
||||||
String fullName = Stream.of(u.getFirstName(), u.getLastName())
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.collect(Collectors.joining(" "));
|
|
||||||
return new ActivityActorDTO(initials.toUpperCase(), u.getColor(), fullName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
public record BatchMetadataRequest(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<UUID> ids) {}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
public record BulkEditError(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String message) {}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
public record BulkEditResult(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int updated,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<BulkEditError> errors) {}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class CreateInviteRequest {
|
|
||||||
private String label;
|
|
||||||
private Integer maxUses;
|
|
||||||
private String prefillFirstName;
|
|
||||||
private String prefillLastName;
|
|
||||||
private String prefillEmail;
|
|
||||||
private List<UUID> groupIds;
|
|
||||||
private LocalDateTime expiresAt;
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,14 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.model.PersonMention;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
|
||||||
public class CreateTranscriptionBlockDTO {
|
public class CreateTranscriptionBlockDTO {
|
||||||
@Min(0)
|
@Min(0)
|
||||||
private int pageNumber;
|
private int pageNumber;
|
||||||
@@ -29,8 +22,4 @@ public class CreateTranscriptionBlockDTO {
|
|||||||
private double height;
|
private double height;
|
||||||
private String text;
|
private String text;
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
@Valid
|
|
||||||
@Builder.Default
|
|
||||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.Pattern;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -11,9 +9,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class CreateUserRequest {
|
public class CreateUserRequest {
|
||||||
@NotBlank
|
private String username;
|
||||||
@Email
|
|
||||||
@Pattern(regexp = "^[^:]+$", message = "Email must not contain a colon")
|
|
||||||
private String email;
|
private String email;
|
||||||
private String initialPassword;
|
private String initialPassword;
|
||||||
private List<UUID> groupIds;
|
private List<UUID> groupIds;
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class DocumentBatchMetadataDTO {
|
|
||||||
private List<String> titles;
|
|
||||||
private UUID senderId;
|
|
||||||
private List<UUID> receiverIds;
|
|
||||||
private LocalDate documentDate;
|
|
||||||
private String location;
|
|
||||||
private List<String> tagNames;
|
|
||||||
private Boolean metadataComplete;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
public record DocumentBatchSummary(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String pdfUrl) {}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for {@code PATCH /api/documents/bulk}. Field semantics:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code tagNames} and {@code receiverIds} are <b>additive</b> —
|
|
||||||
* merged into each document's existing set, never replacing it.</li>
|
|
||||||
* <li>{@code senderId}, {@code documentLocation}, {@code archiveBox},
|
|
||||||
* {@code archiveFolder} are <b>replace-on-non-blank</b> — null/blank
|
|
||||||
* fields are skipped, anything else overwrites.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Kept as a Lombok {@code @Data} POJO (not a record) for symmetry with
|
|
||||||
* the existing {@code DocumentUpdateDTO} and to keep test setup terse —
|
|
||||||
* the per-feature DTOs introduced alongside this one ({@link BulkEditError},
|
|
||||||
* {@link BulkEditResult}, {@link BatchMetadataRequest},
|
|
||||||
* {@link DocumentBatchSummary}) <i>are</i> records because they have no
|
|
||||||
* test-side mutation. Tracked in the cycle-1 review for follow-up.
|
|
||||||
*
|
|
||||||
* <p>Bean-validation caps below defend against payload-amplification: the
|
|
||||||
* 1 MiB SvelteKit proxy cap allows ~26k UUIDs through to the backend, and
|
|
||||||
* Jetty's default body limit is 8 MB. {@code @Size} guards catch malformed
|
|
||||||
* clients without depending on those outer bounds.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
public class DocumentBulkEditDTO {
|
|
||||||
|
|
||||||
// No @Size cap here on purpose: the controller's BULK_EDIT_MAX_IDS check
|
|
||||||
// returns the typed BULK_EDIT_TOO_MANY_IDS error code, which the frontend
|
|
||||||
// maps to a localised "Maximal 500 …" message via Paraglide. A bean-
|
|
||||||
// validation @Size would short-circuit that with a generic VALIDATION_ERROR.
|
|
||||||
private List<UUID> documentIds;
|
|
||||||
|
|
||||||
@Size(max = 200, message = "tagNames must not exceed 200 entries")
|
|
||||||
private List<@Size(max = 200, message = "tagName must not exceed 200 chars") String> tagNames;
|
|
||||||
|
|
||||||
private UUID senderId;
|
|
||||||
|
|
||||||
@Size(max = 200, message = "receiverIds must not exceed 200 entries")
|
|
||||||
private List<UUID> receiverIds;
|
|
||||||
|
|
||||||
@Size(max = 255, message = "documentLocation must not exceed 255 chars")
|
|
||||||
private String documentLocation;
|
|
||||||
|
|
||||||
@Size(max = 255, message = "archiveBox must not exceed 255 chars")
|
|
||||||
private String archiveBox;
|
|
||||||
|
|
||||||
@Size(max = 255, message = "archiveFolder must not exceed 255 chars")
|
|
||||||
private String archiveFolder;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record DocumentSearchItem(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
Document document,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
SearchMatchData matchData,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
int completionPercentage,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<ActivityActorDTO> contributors
|
|
||||||
) {}
|
|
||||||
@@ -1,38 +1,16 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record DocumentSearchResult(
|
public record DocumentSearchResult(List<Document> documents, long total) {
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<DocumentSearchItem> items,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
long totalElements,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
int pageNumber,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
int pageSize,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
int totalPages
|
|
||||||
) {
|
|
||||||
/**
|
/**
|
||||||
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
* Creates a result where total equals the list size.
|
||||||
* don't care about paging. Treats the whole list as page 0 of itself.
|
* No pagination yet — the full matched set is always returned.
|
||||||
|
* When pagination is added, total must come from a DB COUNT query, not list.size().
|
||||||
*/
|
*/
|
||||||
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
public static DocumentSearchResult of(List<Document> documents) {
|
||||||
int size = items.size();
|
return new DocumentSearchResult(documents, documents.size());
|
||||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paged factory used by the service when it has a real Pageable + full match count
|
|
||||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
|
||||||
*/
|
|
||||||
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
|
|
||||||
int pageSize = pageable.getPageSize();
|
|
||||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
|
||||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ public class DocumentUpdateDTO {
|
|||||||
private LocalDate documentDate;
|
private LocalDate documentDate;
|
||||||
private String location;
|
private String location;
|
||||||
private String documentLocation;
|
private String documentLocation;
|
||||||
private String archiveBox;
|
|
||||||
private String archiveFolder;
|
|
||||||
private String transcription;
|
private String transcription;
|
||||||
private String summary;
|
private String summary;
|
||||||
private UUID senderId;
|
private UUID senderId;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used for both create and update of a Geschichte. All fields are optional;
|
|
||||||
* the service applies whatever is non-null. {@code body} is rich-text HTML and
|
|
||||||
* is sanitised against an allow-list before persistence.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public class GeschichteUpdateDTO {
|
|
||||||
private String title;
|
|
||||||
private String body;
|
|
||||||
private GeschichteStatus status;
|
|
||||||
private List<UUID> personIds;
|
|
||||||
private List<UUID> documentIds;
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,9 @@ package org.raddatz.familienarchiv.dto;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record IncompleteDocumentDTO(
|
public record IncompleteDocumentDTO(
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime uploadedAt
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class InviteListItemDTO {
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private UUID id;
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String code;
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String displayCode;
|
|
||||||
private String label;
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private int useCount;
|
|
||||||
private Integer maxUses;
|
|
||||||
private LocalDateTime expiresAt;
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private boolean revoked;
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String status;
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
private String shareableUrl;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
public class InvitePrefillDTO {
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String firstName;
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String lastName;
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String email;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Character-level offset of a highlighted term within a text field.
|
|
||||||
* Offsets are Java {@code String} character positions (UTF-16 code units),
|
|
||||||
* which are identical to JavaScript string positions — consistent end-to-end
|
|
||||||
* for all German BMP characters (ä, ö, ü, ß, etc.).
|
|
||||||
*/
|
|
||||||
public record MatchOffset(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int start,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int length
|
|
||||||
) {}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record MergeTagDTO(@NotNull UUID targetId) {}
|
|
||||||
@@ -17,7 +17,6 @@ public interface PersonSummaryDTO {
|
|||||||
Integer getBirthYear();
|
Integer getBirthYear();
|
||||||
Integer getDeathYear();
|
Integer getDeathYear();
|
||||||
String getNotes();
|
String getNotes();
|
||||||
boolean isFamilyMember();
|
|
||||||
long getDocumentCount();
|
long getDocumentCount();
|
||||||
|
|
||||||
default String getDisplayName() {
|
default String getDisplayName() {
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.raddatz.familienarchiv.model.PersonType;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PersonUpdateDTO {
|
public class PersonUpdateDTO {
|
||||||
@NotNull
|
|
||||||
private PersonType personType;
|
|
||||||
@Size(max = 50)
|
@Size(max = 50)
|
||||||
private String title;
|
private String title;
|
||||||
@Size(max = 100)
|
@Size(max = 100)
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class RegisterRequest {
|
|
||||||
@NotBlank
|
|
||||||
private String code;
|
|
||||||
@NotBlank
|
|
||||||
@Email
|
|
||||||
private String email;
|
|
||||||
@NotBlank
|
|
||||||
private String password;
|
|
||||||
private String firstName;
|
|
||||||
private String lastName;
|
|
||||||
private boolean notifyOnMention = true;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match signals for a single document in a full-text search result.
|
|
||||||
* All fields are non-null except {@code transcriptionSnippet} and {@code summarySnippet},
|
|
||||||
* which are null when the respective field did not match the query.
|
|
||||||
*/
|
|
||||||
public record SearchMatchData(
|
|
||||||
/**
|
|
||||||
* Best-ranked matching transcription line, or null if no block matched.
|
|
||||||
*/
|
|
||||||
String transcriptionSnippet,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Character offsets of highlighted terms within the document title.
|
|
||||||
* Empty when the title did not contribute to the match.
|
|
||||||
*/
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<MatchOffset> titleOffsets,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the sender's name matched the query.
|
|
||||||
*/
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
boolean senderMatched,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IDs of receiver persons whose names matched the query.
|
|
||||||
*/
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<UUID> matchedReceiverIds,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IDs of tags whose names matched the query.
|
|
||||||
*/
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<UUID> matchedTagIds,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Character offsets of highlighted terms within the transcription snippet.
|
|
||||||
* Empty when no transcription block matched or the snippet has no highlights.
|
|
||||||
*/
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<MatchOffset> snippetOffsets,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Highlighted summary excerpt, or null if the summary did not match the query.
|
|
||||||
*/
|
|
||||||
String summarySnippet,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Character offsets of highlighted terms within the summary snippet.
|
|
||||||
* Empty when the summary did not match or has no highlights.
|
|
||||||
*/
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<MatchOffset> summaryOffsets
|
|
||||||
) {
|
|
||||||
/** Canonical "no match data" value for a single document. */
|
|
||||||
public static SearchMatchData empty() {
|
|
||||||
return new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
/** Determines how multiple selected tag filters are combined in a document search. */
|
|
||||||
public enum TagOperator {
|
|
||||||
/** Every tag set must match (default). */
|
|
||||||
AND,
|
|
||||||
/** At least one tag set must match. */
|
|
||||||
OR
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
public record TagTreeNodeDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
|
|
||||||
String color,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
|
|
||||||
List<TagTreeNodeDTO> children,
|
|
||||||
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record TagUpdateDTO(String name, UUID parentId, String color) {}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.OcrTrainingRun;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public record TrainingHistoryResponse(
|
|
||||||
List<OcrTrainingRun> runs,
|
|
||||||
Map<String, String> personNames
|
|
||||||
) {}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.OcrTrainingRun;
|
|
||||||
import org.raddatz.familienarchiv.model.SenderModel;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public record TrainingInfoResponse(
|
|
||||||
int availableBlocks,
|
|
||||||
int totalOcrBlocks,
|
|
||||||
int availableDocuments,
|
|
||||||
int availableSegBlocks,
|
|
||||||
boolean ocrServiceAvailable,
|
|
||||||
OcrTrainingRun lastRun,
|
|
||||||
List<OcrTrainingRun> runs,
|
|
||||||
Map<String, String> personNames,
|
|
||||||
List<SenderModel> senderModels
|
|
||||||
) {}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record TranscriptionQueueItemDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
|
||||||
LocalDate documentDate,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotationCount,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int textedBlockCount,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int reviewedBlockCount,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> contributors,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean hasMoreContributors
|
|
||||||
) {}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Weekly activity pulse for the Mission Control Strip column headers.
|
|
||||||
* Counts documents that received new work in each pipeline stage
|
|
||||||
* during the last 7 days.
|
|
||||||
*/
|
|
||||||
public record TranscriptionWeeklyStatsDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long segmentationCount,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long transcriptionCount
|
|
||||||
) {}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record TriggerSenderTrainingDTO(
|
|
||||||
@NotNull
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
UUID personId
|
|
||||||
) {}
|
|
||||||
@@ -1,24 +1,13 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.model.PersonMention;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
|
||||||
public class UpdateTranscriptionBlockDTO {
|
public class UpdateTranscriptionBlockDTO {
|
||||||
private String text;
|
private String text;
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
@Valid
|
|
||||||
@Builder.Default
|
|
||||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ public enum ErrorCode {
|
|||||||
PERSON_NOT_FOUND,
|
PERSON_NOT_FOUND,
|
||||||
/** A person name alias with the given ID does not exist. 404 */
|
/** A person name alias with the given ID does not exist. 404 */
|
||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
|
||||||
INVALID_PERSON_TYPE,
|
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
DOCUMENT_NOT_FOUND,
|
DOCUMENT_NOT_FOUND,
|
||||||
@@ -39,20 +38,6 @@ public enum ErrorCode {
|
|||||||
/** A mass import is already in progress; only one can run at a time. 409 */
|
/** A mass import is already in progress; only one can run at a time. 409 */
|
||||||
IMPORT_ALREADY_RUNNING,
|
IMPORT_ALREADY_RUNNING,
|
||||||
|
|
||||||
// --- Thumbnails ---
|
|
||||||
/** A thumbnail backfill is already in progress; only one can run at a time. 409 */
|
|
||||||
THUMBNAIL_BACKFILL_ALREADY_RUNNING,
|
|
||||||
|
|
||||||
// --- Invites ---
|
|
||||||
/** The invite code does not exist. 404 */
|
|
||||||
INVITE_NOT_FOUND,
|
|
||||||
/** The invite has already reached its use limit. 409 */
|
|
||||||
INVITE_EXHAUSTED,
|
|
||||||
/** The invite has been revoked by an admin. 409 */
|
|
||||||
INVITE_REVOKED,
|
|
||||||
/** The invite has passed its expiry date. 410 */
|
|
||||||
INVITE_EXPIRED,
|
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
/** The request is not authenticated. 401 */
|
/** The request is not authenticated. 401 */
|
||||||
UNAUTHORIZED,
|
UNAUTHORIZED,
|
||||||
@@ -92,40 +77,10 @@ public enum ErrorCode {
|
|||||||
OCR_PROCESSING_FAILED,
|
OCR_PROCESSING_FAILED,
|
||||||
/** A training run is already in progress. 409 */
|
/** A training run is already in progress. 409 */
|
||||||
TRAINING_ALREADY_RUNNING,
|
TRAINING_ALREADY_RUNNING,
|
||||||
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
|
||||||
OCR_TRAINING_CONFLICT,
|
|
||||||
|
|
||||||
// --- Relationships (Stammbaum) ---
|
|
||||||
/** A relationship row with the given ID does not exist. 404 */
|
|
||||||
RELATIONSHIP_NOT_FOUND,
|
|
||||||
/** Adding this relationship would create a cycle (e.g. reverse PARENT_OF already exists). 409 */
|
|
||||||
CIRCULAR_RELATIONSHIP,
|
|
||||||
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
|
||||||
DUPLICATE_RELATIONSHIP,
|
|
||||||
|
|
||||||
// --- Geschichten (Stories) ---
|
|
||||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
|
||||||
GESCHICHTE_NOT_FOUND,
|
|
||||||
|
|
||||||
// --- Tags ---
|
|
||||||
/** A tag with the given ID does not exist. 404 */
|
|
||||||
TAG_NOT_FOUND,
|
|
||||||
/** The supplied color token is not in the allowed palette. 400 */
|
|
||||||
INVALID_TAG_COLOR,
|
|
||||||
/** Setting this parent would create a cycle in the tag hierarchy. 400 */
|
|
||||||
TAG_CYCLE_DETECTED,
|
|
||||||
/** Merge source and target are the same tag. 400 */
|
|
||||||
TAG_MERGE_SELF,
|
|
||||||
/** The merge target is a descendant of the source tag. 400 */
|
|
||||||
TAG_MERGE_INVALID_TARGET,
|
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
|
||||||
BATCH_TOO_LARGE,
|
|
||||||
/** Bulk edit request exceeds the per-request document ID cap. 400 */
|
|
||||||
BULK_EDIT_TOO_MANY_IDS,
|
|
||||||
/** An unexpected server-side error occurred. 500 */
|
/** An unexpected server-side error occurred. 500 */
|
||||||
INTERNAL_ERROR,
|
INTERNAL_ERROR,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.Pattern;
|
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
@@ -19,12 +16,8 @@ import java.util.HashSet;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import jakarta.persistence.PostLoad;
|
|
||||||
import jakarta.persistence.PrePersist;
|
|
||||||
import jakarta.persistence.PreUpdate;
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "users")
|
@Table(name = "users") // Tabellenname in Postgres
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@@ -37,26 +30,26 @@ public class AppUser {
|
|||||||
private UUID id;
|
private UUID id;
|
||||||
|
|
||||||
@Column(unique = true, nullable = false)
|
@Column(unique = true, nullable = false)
|
||||||
@NotBlank
|
|
||||||
@Email
|
|
||||||
@Pattern(regexp = "^[^:]+$", message = "Email must not contain a colon")
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String email;
|
private String username;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||||
private String password;
|
private String password; // Wird verschlüsselt gespeichert (BCrypt)
|
||||||
|
|
||||||
private String firstName;
|
private String firstName;
|
||||||
private String lastName;
|
private String lastName;
|
||||||
private LocalDate birthDate;
|
private LocalDate birthDate;
|
||||||
|
|
||||||
|
@Column(unique = true)
|
||||||
|
private String email;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String contact;
|
private String contact;
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private boolean enabled = true;
|
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@@ -68,6 +61,7 @@ public class AppUser {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private boolean notifyOnMention = false;
|
private boolean notifyOnMention = false;
|
||||||
|
|
||||||
|
// Ein User kann in mehreren Gruppen sein
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
|
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@@ -78,48 +72,31 @@ public class AppUser {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
@Builder.Default
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String color = "";
|
|
||||||
|
|
||||||
private static final String[] PALETTE = {
|
|
||||||
"#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8"
|
|
||||||
};
|
|
||||||
|
|
||||||
public static String computeColor(UUID id) {
|
|
||||||
return PALETTE[Math.abs(id.hashCode()) % PALETTE.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
@PrePersist
|
|
||||||
@PreUpdate
|
|
||||||
@PostLoad
|
|
||||||
void deriveColor() {
|
|
||||||
if (id != null && (color == null || color.isEmpty())) {
|
|
||||||
this.color = computeColor(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasPermission(String permission) {
|
public boolean hasPermission(String permission) {
|
||||||
if (groups == null || groups.isEmpty()) {
|
if (groups == null || groups.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.groups.stream().anyMatch(group -> group.getPermissions().contains(permission));
|
return this.groups.stream().anyMatch(group -> group.getPermissions().contains(permission));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppUser updateFromRequest(CreateUserRequest request, PasswordEncoder passwordEncoder, Set<UserGroup> groups) {
|
public AppUser updateFromRequest(CreateUserRequest request, PasswordEncoder passwordEncoder, Set<UserGroup> groups) {
|
||||||
if (request.getEmail() != null && !request.getEmail().isBlank()) {
|
if (request.getUsername() != null && !request.getUsername().isBlank()) {
|
||||||
this.email = request.getEmail();
|
this.username = request.getUsername();
|
||||||
}
|
|
||||||
|
|
||||||
if (request.getInitialPassword() != null && !request.getInitialPassword().isBlank()) {
|
|
||||||
this.password = passwordEncoder.encode(request.getInitialPassword());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groups != null && !groups.isEmpty()) {
|
|
||||||
this.groups = groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.getEmail() != null && !request.getEmail().isBlank()) {
|
||||||
|
this.email = request.getEmail();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getInitialPassword() != null && !request.getInitialPassword().isBlank()) {
|
||||||
|
this.password = passwordEncoder.encode(request.getInitialPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups != null && !groups.isEmpty()) {
|
||||||
|
this.groups = groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -46,20 +43,6 @@ public class Document {
|
|||||||
@Column(name = "file_hash", length = 64)
|
@Column(name = "file_hash", length = 64)
|
||||||
private String fileHash;
|
private String fileHash;
|
||||||
|
|
||||||
// S3 key of the generated thumbnail (e.g. "thumbnails/{docId}.jpg"); null until generated
|
|
||||||
@Column(name = "thumbnail_key")
|
|
||||||
private String thumbnailKey;
|
|
||||||
|
|
||||||
@Column(name = "thumbnail_generated_at")
|
|
||||||
private LocalDateTime thumbnailGeneratedAt;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "thumbnail_aspect", length = 16)
|
|
||||||
private ThumbnailAspect thumbnailAspect;
|
|
||||||
|
|
||||||
@Column(name = "page_count")
|
|
||||||
private Integer pageCount;
|
|
||||||
|
|
||||||
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||||
@Column(name = "original_filename", nullable = false)
|
@Column(name = "original_filename", nullable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@@ -134,19 +117,4 @@ public class Document {
|
|||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||||
|
|
||||||
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
|
||||||
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
|
||||||
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
|
||||||
// this URL changes whenever the underlying file does. Dropping the query param
|
|
||||||
// would let browsers serve a stale thumbnail for a year after the file is
|
|
||||||
// replaced, and shared caches could leak one user's thumbnail to another
|
|
||||||
// (CWE-525).
|
|
||||||
@JsonProperty("thumbnailUrl")
|
|
||||||
public String getThumbnailUrl() {
|
|
||||||
if (thumbnailKey == null) return null;
|
|
||||||
String base = "/api/documents/" + id + "/thumbnail";
|
|
||||||
if (thumbnailGeneratedAt == null) return base;
|
|
||||||
return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
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.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "geschichten")
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class Geschichte {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String title;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String body;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
@Builder.Default
|
|
||||||
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "author_id")
|
|
||||||
private AppUser author;
|
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
|
||||||
@JoinTable(name = "geschichten_persons",
|
|
||||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
|
||||||
inverseJoinColumns = @JoinColumn(name = "person_id"))
|
|
||||||
@Builder.Default
|
|
||||||
private Set<Person> persons = new HashSet<>();
|
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
|
||||||
@JoinTable(name = "geschichten_documents",
|
|
||||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
|
||||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
|
||||||
@Builder.Default
|
|
||||||
private Set<Document> documents = new HashSet<>();
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(updatable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@UpdateTimestamp
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
@Column(name = "published_at")
|
|
||||||
private LocalDateTime publishedAt;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
|
||||||
|
|
||||||
public enum GeschichteStatus {
|
|
||||||
DRAFT,
|
|
||||||
PUBLISHED
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "invite_tokens")
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class InviteToken {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(nullable = false, unique = true, length = 10)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String code;
|
|
||||||
|
|
||||||
private String label;
|
|
||||||
|
|
||||||
private Integer maxUses;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
@Builder.Default
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private int useCount = 0;
|
|
||||||
|
|
||||||
private String prefillFirstName;
|
|
||||||
private String prefillLastName;
|
|
||||||
private String prefillEmail;
|
|
||||||
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
|
||||||
@CollectionTable(name = "invite_token_group_ids", joinColumns = @JoinColumn(name = "invite_token_id"))
|
|
||||||
@Column(name = "group_id")
|
|
||||||
@Builder.Default
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private Set<UUID> groupIds = new HashSet<>();
|
|
||||||
|
|
||||||
private LocalDateTime expiresAt;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "created_by", nullable = false)
|
|
||||||
private AppUser createdBy;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
@Builder.Default
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private boolean revoked = false;
|
|
||||||
|
|
||||||
public boolean isExhausted() {
|
|
||||||
return maxUses != null && useCount >= maxUses;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isExpired() {
|
|
||||||
return expiresAt != null && expiresAt.isBefore(LocalDateTime.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isActive() {
|
|
||||||
return !revoked && !isExhausted() && !isExpired();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,9 +59,6 @@ public class OcrTrainingRun {
|
|||||||
@Column(name = "triggered_by")
|
@Column(name = "triggered_by")
|
||||||
private UUID triggeredBy;
|
private UUID triggeredBy;
|
||||||
|
|
||||||
@Column(name = "person_id")
|
|
||||||
private UUID personId;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
|||||||
@@ -47,11 +47,6 @@ public class Person {
|
|||||||
private Integer birthYear;
|
private Integer birthYear;
|
||||||
private Integer deathYear;
|
private Integer deathYear;
|
||||||
|
|
||||||
@Column(name = "family_member", nullable = false)
|
|
||||||
@Builder.Default
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private boolean familyMember = false;
|
|
||||||
|
|
||||||
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
||||||
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
||||||
// separate DB roundtrip while respecting domain boundaries.
|
// separate DB roundtrip while respecting domain boundaries.
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Embeddable;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Embeddable
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
public class PersonMention {
|
|
||||||
|
|
||||||
@NotNull
|
|
||||||
@Column(name = "person_id", nullable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private UUID personId;
|
|
||||||
|
|
||||||
@NotNull
|
|
||||||
@Size(max = 200)
|
|
||||||
@Column(name = "display_name", nullable = false, length = 200)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
// Archival: the text the transcriber typed after @. Never updated on person rename.
|
|
||||||
private String displayName;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "sender_models")
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class SenderModel {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(name = "person_id", nullable = false, unique = true)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private UUID personId;
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
@Column(name = "model_path", nullable = false)
|
|
||||||
private String modelPath;
|
|
||||||
|
|
||||||
@Column
|
|
||||||
private Double accuracy;
|
|
||||||
|
|
||||||
@Column
|
|
||||||
private Double cer;
|
|
||||||
|
|
||||||
@Column(name = "corrected_lines_at_training", nullable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private int correctedLinesAtTraining;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private Instant createdAt;
|
|
||||||
|
|
||||||
@UpdateTimestamp
|
|
||||||
@Column(name = "updated_at", nullable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private Instant updatedAt;
|
|
||||||
}
|
|
||||||
@@ -20,11 +20,4 @@ public class Tag {
|
|||||||
@Column(unique = true, nullable = false)
|
@Column(unique = true, nullable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
/** UUID of the parent tag, or null for root-level tags. */
|
|
||||||
@Column(name = "parent_id")
|
|
||||||
private UUID parentId;
|
|
||||||
|
|
||||||
/** Color token name (e.g. "sage"), only set on root-level tags. Null means no color. */
|
|
||||||
private String color;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
|
||||||
|
|
||||||
public enum ThumbnailAspect {
|
|
||||||
PORTRAIT,
|
|
||||||
LANDSCAPE
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
public enum TrainingStatus {
|
public enum TrainingStatus {
|
||||||
QUEUED,
|
|
||||||
RUNNING,
|
RUNNING,
|
||||||
DONE,
|
DONE,
|
||||||
FAILED
|
FAILED
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -35,16 +33,6 @@ public class TranscriptionBlock {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String text;
|
private String text;
|
||||||
|
|
||||||
// EAGER: mention set is bounded by block text length (typically < 20 entries).
|
|
||||||
// Switching back to LAZY requires callers to be inside an open Hibernate session.
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
|
||||||
@CollectionTable(
|
|
||||||
name = "transcription_block_mentioned_persons",
|
|
||||||
joinColumns = @JoinColumn(name = "block_id"))
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
@Builder.Default
|
|
||||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
|
||||||
|
|
||||||
@Column(length = 200)
|
@Column(length = 200)
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.*;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "person_relationships")
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
@ToString(exclude = "notes")
|
|
||||||
public class PersonRelationship {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "person_id", nullable = false)
|
|
||||||
@JsonIgnore
|
|
||||||
private Person person;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "related_person_id", nullable = false)
|
|
||||||
@JsonIgnore
|
|
||||||
private Person relatedPerson;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "relation_type", nullable = false, length = 30)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private RelationType relationType;
|
|
||||||
|
|
||||||
@Column(name = "from_year")
|
|
||||||
private Integer fromYear;
|
|
||||||
|
|
||||||
@Column(name = "to_year")
|
|
||||||
private Integer toYear;
|
|
||||||
|
|
||||||
@Column(length = 2000)
|
|
||||||
private String notes;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(name = "created_at", updatable = false, nullable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private Instant createdAt;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public interface PersonRelationshipRepository extends JpaRepository<PersonRelationship, UUID> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bulk fetch for the network endpoint — pulls only edges of the given types.
|
|
||||||
* The service filters by family_member afterwards.
|
|
||||||
*/
|
|
||||||
@Query("SELECT r FROM PersonRelationship r " +
|
|
||||||
"JOIN FETCH r.person " +
|
|
||||||
"JOIN FETCH r.relatedPerson " +
|
|
||||||
"WHERE r.relationType IN :types")
|
|
||||||
List<PersonRelationship> findAllByRelationTypeIn(@Param("types") Collection<RelationType> types);
|
|
||||||
|
|
||||||
/** Used for the circular-PARENT_OF check in {@code addRelationship}. */
|
|
||||||
boolean existsByPersonIdAndRelatedPersonIdAndRelationType(
|
|
||||||
UUID personId, UUID relatedPersonId, RelationType relationType);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All edges incident on {@code personId} (either side) restricted to the given types.
|
|
||||||
* Used by the inference service to load a person's local subgraph for BFS.
|
|
||||||
*/
|
|
||||||
@Query("SELECT r FROM PersonRelationship r " +
|
|
||||||
"WHERE (r.person.id = :personId OR r.relatedPerson.id = :personId) " +
|
|
||||||
"AND r.relationType IN :types")
|
|
||||||
List<PersonRelationship> findAllByPersonIdOrRelatedPersonIdAndRelationTypeIn(
|
|
||||||
@Param("personId") UUID personId,
|
|
||||||
@Param("types") Collection<RelationType> types);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All edges incident on {@code personId} (either side), all types.
|
|
||||||
* Used by the "direct relationships" listings (person edit, side panel).
|
|
||||||
*/
|
|
||||||
@Query("SELECT r FROM PersonRelationship r " +
|
|
||||||
"JOIN FETCH r.person " +
|
|
||||||
"JOIN FETCH r.relatedPerson " +
|
|
||||||
"WHERE r.person.id = :personId OR r.relatedPerson.id = :personId")
|
|
||||||
List<PersonRelationship> findAllByPersonIdOrRelatedPersonId(@Param("personId") UUID personId);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}.
|
|
||||||
* A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}.
|
|
||||||
*
|
|
||||||
* <p>Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric
|
|
||||||
* tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched.
|
|
||||||
*/
|
|
||||||
public enum RelationToken {
|
|
||||||
UP,
|
|
||||||
DOWN,
|
|
||||||
SPOUSE,
|
|
||||||
SIBLING;
|
|
||||||
|
|
||||||
public RelationToken reverse() {
|
|
||||||
return switch (this) {
|
|
||||||
case UP -> DOWN;
|
|
||||||
case DOWN -> UP;
|
|
||||||
case SPOUSE -> SPOUSE;
|
|
||||||
case SIBLING -> SIBLING;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Family-network relationship taxonomy.
|
|
||||||
*
|
|
||||||
* <p>Symmetric types ({@link #SPOUSE_OF}, {@link #SIBLING_OF}) are stored once;
|
|
||||||
* the inference service walks them in both directions. {@link #PARENT_OF} is
|
|
||||||
* directional: A PARENT_OF B means A is the parent.
|
|
||||||
*/
|
|
||||||
public enum RelationType {
|
|
||||||
PARENT_OF,
|
|
||||||
SPOUSE_OF,
|
|
||||||
SIBLING_OF,
|
|
||||||
FRIEND,
|
|
||||||
COLLEAGUE,
|
|
||||||
EMPLOYER,
|
|
||||||
DOCTOR,
|
|
||||||
NEIGHBOR,
|
|
||||||
OTHER
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship;
|
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.FamilyMemberPatchDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stammbaum API. Endpoints split across two roots:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code /api/network} — the family graph</li>
|
|
||||||
* <li>{@code /api/persons/{id}/...} — per-person relationship operations
|
|
||||||
* (PersonController is intentionally left untouched)</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class RelationshipController {
|
|
||||||
|
|
||||||
private final RelationshipService relationshipService;
|
|
||||||
|
|
||||||
// READ endpoints carry no @RequirePermission: all authenticated users may read the family graph.
|
|
||||||
// Unauthenticated requests are rejected by Spring Security's anyRequest().authenticated() rule.
|
|
||||||
|
|
||||||
@GetMapping("/api/network")
|
|
||||||
public NetworkDTO getNetwork() {
|
|
||||||
return relationshipService.getFamilyNetwork();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/api/persons/{id}/relationships")
|
|
||||||
public List<RelationshipDTO> getRelationships(@PathVariable UUID id) {
|
|
||||||
return relationshipService.getRelationships(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/api/persons/{id}/inferred-relationships")
|
|
||||||
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(@PathVariable UUID id) {
|
|
||||||
return relationshipService.getInferredRelationships(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/api/persons/{aId}/relationship-to/{bId}")
|
|
||||||
public InferredRelationshipDTO getRelationshipBetween(@PathVariable UUID aId, @PathVariable UUID bId) {
|
|
||||||
return relationshipService.getRelationshipBetween(aId, bId)
|
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
|
||||||
ErrorCode.RELATIONSHIP_NOT_FOUND, "No relationship path between " + aId + " and " + bId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/api/persons/{id}/relationships")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public ResponseEntity<RelationshipDTO> addRelationship(
|
|
||||||
@PathVariable UUID id,
|
|
||||||
@Valid @RequestBody CreateRelationshipRequest dto) {
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED)
|
|
||||||
.body(relationshipService.addRelationship(id, dto));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
|
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public void deleteRelationship(@PathVariable UUID id, @PathVariable UUID relId) {
|
|
||||||
relationshipService.deleteRelationship(id, relId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PatchMapping("/api/persons/{id}/family-member")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public Person patchFamilyMember(@PathVariable UUID id, @RequestBody FamilyMemberPatchDTO dto) {
|
|
||||||
return relationshipService.setFamilyMember(id, dto.familyMember());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
|
||||||
import org.raddatz.familienarchiv.service.PersonService;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derives indirect family relationships by BFS over the family-graph subset
|
|
||||||
* (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are
|
|
||||||
* not consulted. Siblings are also derived from shared parents — no SIBLING_OF
|
|
||||||
* row is required.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class RelationshipInferenceService {
|
|
||||||
|
|
||||||
// 8 hops covers great-grandparents ↔ great-great-grandchildren and second cousins —
|
|
||||||
// the practical horizon for a 1899–1950 family archive. Paths longer than this are
|
|
||||||
// classified as LABEL_DISTANT and rarely carry meaningful relationship labels.
|
|
||||||
static final int MAX_DEPTH = 8;
|
|
||||||
|
|
||||||
/** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */
|
|
||||||
static final String LABEL_DISTANT = "distant";
|
|
||||||
|
|
||||||
private static final Map<List<RelationToken>, String> LABEL_MAP = buildLabelMap();
|
|
||||||
|
|
||||||
private final PersonRelationshipRepository relationshipRepository;
|
|
||||||
private final PersonService personService;
|
|
||||||
|
|
||||||
private static Map<List<RelationToken>, String> buildLabelMap() {
|
|
||||||
Map<List<RelationToken>, String> m = new HashMap<>();
|
|
||||||
m.put(List.of(RelationToken.UP), "parent");
|
|
||||||
m.put(List.of(RelationToken.DOWN), "child");
|
|
||||||
m.put(List.of(RelationToken.SPOUSE), "spouse");
|
|
||||||
m.put(List.of(RelationToken.SIBLING), "sibling");
|
|
||||||
m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent");
|
|
||||||
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild");
|
|
||||||
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent");
|
|
||||||
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild");
|
|
||||||
m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt");
|
|
||||||
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew");
|
|
||||||
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt");
|
|
||||||
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew");
|
|
||||||
m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent");
|
|
||||||
m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child");
|
|
||||||
m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw");
|
|
||||||
m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw");
|
|
||||||
m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1");
|
|
||||||
return Collections.unmodifiableMap(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shortest token path from {@code from} to {@code to}, or empty if unreachable
|
|
||||||
* within {@link #MAX_DEPTH} hops. Package-private to permit direct path
|
|
||||||
* assertions in unit tests.
|
|
||||||
*/
|
|
||||||
Optional<List<RelationToken>> findShortestPath(UUID from, UUID to) {
|
|
||||||
if (from.equals(to)) return Optional.empty();
|
|
||||||
Map<UUID, List<Edge>> adj = buildAdjacency();
|
|
||||||
return bfs(adj, from, to);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Two-sided label between A and B. {@code labelFromA} reads "B is my <labelFromA>". */
|
|
||||||
public Optional<InferredRelationshipDTO> infer(UUID a, UUID b) {
|
|
||||||
Optional<List<RelationToken>> aToB = findShortestPath(a, b);
|
|
||||||
if (aToB.isEmpty()) return Optional.empty();
|
|
||||||
List<RelationToken> path = aToB.get();
|
|
||||||
return Optional.of(new InferredRelationshipDTO(
|
|
||||||
labelFor(path),
|
|
||||||
labelFor(reversePath(path)),
|
|
||||||
path.size()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */
|
|
||||||
public List<InferredRelationshipWithPersonDTO> findAllFor(UUID personId) {
|
|
||||||
Map<UUID, List<Edge>> adj = buildAdjacency();
|
|
||||||
Map<UUID, List<RelationToken>> shortestPaths = bfsAll(adj, personId);
|
|
||||||
shortestPaths.remove(personId);
|
|
||||||
if (shortestPaths.isEmpty()) return List.of();
|
|
||||||
|
|
||||||
List<UUID> ids = new ArrayList<>(shortestPaths.keySet());
|
|
||||||
Map<UUID, Person> byId = new HashMap<>();
|
|
||||||
for (Person p : personService.getAllById(ids)) {
|
|
||||||
byId.put(p.getId(), p);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<InferredRelationshipWithPersonDTO> out = new ArrayList<>();
|
|
||||||
for (UUID id : ids) {
|
|
||||||
Person p = byId.get(id);
|
|
||||||
if (p == null) continue;
|
|
||||||
List<RelationToken> path = shortestPaths.get(id);
|
|
||||||
PersonNodeDTO node = new PersonNodeDTO(
|
|
||||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
|
|
||||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
|
||||||
}
|
|
||||||
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
|
||||||
.thenComparing(d -> d.person().displayName()));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String labelFor(List<RelationToken> path) {
|
|
||||||
String specific = LABEL_MAP.get(path);
|
|
||||||
return specific != null ? specific : LABEL_DISTANT;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<RelationToken> reversePath(List<RelationToken> path) {
|
|
||||||
List<RelationToken> reversed = new ArrayList<>(path.size());
|
|
||||||
for (int i = path.size() - 1; i >= 0; i--) {
|
|
||||||
reversed.add(path.get(i).reverse());
|
|
||||||
}
|
|
||||||
return List.copyOf(reversed);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<UUID, List<Edge>> buildAdjacency() {
|
|
||||||
List<PersonRelationship> edges = relationshipRepository.findAllByRelationTypeIn(
|
|
||||||
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
|
|
||||||
Map<UUID, List<Edge>> adj = new HashMap<>();
|
|
||||||
Map<UUID, List<UUID>> parentToChildren = new HashMap<>();
|
|
||||||
|
|
||||||
for (PersonRelationship e : edges) {
|
|
||||||
UUID a = e.getPerson().getId();
|
|
||||||
UUID b = e.getRelatedPerson().getId();
|
|
||||||
switch (e.getRelationType()) {
|
|
||||||
case PARENT_OF -> {
|
|
||||||
addEdge(adj, a, b, RelationToken.DOWN);
|
|
||||||
addEdge(adj, b, a, RelationToken.UP);
|
|
||||||
parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
|
|
||||||
}
|
|
||||||
case SPOUSE_OF -> {
|
|
||||||
addEdge(adj, a, b, RelationToken.SPOUSE);
|
|
||||||
addEdge(adj, b, a, RelationToken.SPOUSE);
|
|
||||||
}
|
|
||||||
case SIBLING_OF -> {
|
|
||||||
addEdge(adj, a, b, RelationToken.SIBLING);
|
|
||||||
addEdge(adj, b, a, RelationToken.SIBLING);
|
|
||||||
}
|
|
||||||
default -> { /* family graph excludes other types */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (List<UUID> children : parentToChildren.values()) {
|
|
||||||
for (int i = 0; i < children.size(); i++) {
|
|
||||||
for (int j = i + 1; j < children.size(); j++) {
|
|
||||||
UUID c1 = children.get(i);
|
|
||||||
UUID c2 = children.get(j);
|
|
||||||
addEdge(adj, c1, c2, RelationToken.SIBLING);
|
|
||||||
addEdge(adj, c2, c1, RelationToken.SIBLING);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return adj;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void addEdge(Map<UUID, List<Edge>> adj, UUID from, UUID to, RelationToken token) {
|
|
||||||
adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Optional<List<RelationToken>> bfs(Map<UUID, List<Edge>> adj, UUID from, UUID to) {
|
|
||||||
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
|
|
||||||
shortest.put(from, List.of());
|
|
||||||
Deque<UUID> queue = new ArrayDeque<>();
|
|
||||||
queue.add(from);
|
|
||||||
while (!queue.isEmpty()) {
|
|
||||||
UUID curr = queue.poll();
|
|
||||||
List<RelationToken> currPath = shortest.get(curr);
|
|
||||||
if (currPath.size() >= MAX_DEPTH) continue;
|
|
||||||
for (Edge e : adj.getOrDefault(curr, List.of())) {
|
|
||||||
if (shortest.containsKey(e.target())) continue;
|
|
||||||
List<RelationToken> nextPath = append(currPath, e.token());
|
|
||||||
shortest.put(e.target(), nextPath);
|
|
||||||
if (e.target().equals(to)) return Optional.of(nextPath);
|
|
||||||
queue.add(e.target());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<UUID, List<RelationToken>> bfsAll(Map<UUID, List<Edge>> adj, UUID from) {
|
|
||||||
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
|
|
||||||
shortest.put(from, List.of());
|
|
||||||
Deque<UUID> queue = new ArrayDeque<>();
|
|
||||||
queue.add(from);
|
|
||||||
while (!queue.isEmpty()) {
|
|
||||||
UUID curr = queue.poll();
|
|
||||||
List<RelationToken> currPath = shortest.get(curr);
|
|
||||||
if (currPath.size() >= MAX_DEPTH) continue;
|
|
||||||
for (Edge e : adj.getOrDefault(curr, List.of())) {
|
|
||||||
if (shortest.containsKey(e.target())) continue;
|
|
||||||
List<RelationToken> nextPath = append(currPath, e.token());
|
|
||||||
shortest.put(e.target(), nextPath);
|
|
||||||
queue.add(e.target());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return shortest;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<RelationToken> append(List<RelationToken> prefix, RelationToken next) {
|
|
||||||
List<RelationToken> out = new ArrayList<>(prefix.size() + 1);
|
|
||||||
out.addAll(prefix);
|
|
||||||
out.add(next);
|
|
||||||
return List.copyOf(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Edge(UUID target, RelationToken token) {}
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
|
||||||
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
|
||||||
import org.raddatz.familienarchiv.service.PersonService;
|
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the {@code person_relationships} table and the family_member flag.
|
|
||||||
* Always orchestrates {@link PersonService} for cross-domain access — never
|
|
||||||
* touches {@link org.raddatz.familienarchiv.repository.PersonRepository}.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class RelationshipService {
|
|
||||||
|
|
||||||
private final PersonRelationshipRepository relationshipRepository;
|
|
||||||
private final PersonService personService;
|
|
||||||
private final RelationshipInferenceService inferenceService;
|
|
||||||
|
|
||||||
public List<RelationshipDTO> getRelationships(UUID personId) {
|
|
||||||
personService.getById(personId);
|
|
||||||
List<PersonRelationship> rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId);
|
|
||||||
return rels.stream().map(RelationshipService::toDTO).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(UUID personId) {
|
|
||||||
personService.getById(personId);
|
|
||||||
return inferenceService.findAllFor(personId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<InferredRelationshipDTO> getRelationshipBetween(UUID a, UUID b) {
|
|
||||||
personService.getById(a);
|
|
||||||
personService.getById(b);
|
|
||||||
return inferenceService.infer(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
public NetworkDTO getFamilyNetwork() {
|
|
||||||
// Two queries: 1 for nodes (family members), 1 for edges (family-graph types).
|
|
||||||
List<Person> familyMembers = personService.findAllFamilyMembers();
|
|
||||||
Set<UUID> familyIds = new HashSet<>(familyMembers.size());
|
|
||||||
List<PersonNodeDTO> nodes = new ArrayList<>(familyMembers.size());
|
|
||||||
for (Person p : familyMembers) {
|
|
||||||
familyIds.add(p.getId());
|
|
||||||
nodes.add(new PersonNodeDTO(
|
|
||||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
|
|
||||||
}
|
|
||||||
|
|
||||||
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
|
||||||
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
|
|
||||||
|
|
||||||
List<RelationshipDTO> edges = new ArrayList<>();
|
|
||||||
for (PersonRelationship r : familyEdges) {
|
|
||||||
UUID p = r.getPerson().getId();
|
|
||||||
UUID rp = r.getRelatedPerson().getId();
|
|
||||||
if (familyIds.contains(p) && familyIds.contains(rp)) {
|
|
||||||
edges.add(toDTO(r));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new NetworkDTO(nodes, edges);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
|
||||||
if (personId.equals(dto.relatedPersonId())) {
|
|
||||||
throw DomainException.badRequest(
|
|
||||||
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
|
||||||
}
|
|
||||||
Person person = personService.getById(personId);
|
|
||||||
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
|
||||||
|
|
||||||
validateYears(dto.fromYear(), dto.toYear());
|
|
||||||
|
|
||||||
if (dto.relationType() == RelationType.PARENT_OF
|
|
||||||
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
|
||||||
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
|
|
||||||
throw DomainException.conflict(
|
|
||||||
ErrorCode.CIRCULAR_RELATIONSHIP,
|
|
||||||
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonRelationship rel = PersonRelationship.builder()
|
|
||||||
.person(person)
|
|
||||||
.relatedPerson(relatedPerson)
|
|
||||||
.relationType(dto.relationType())
|
|
||||||
.fromYear(dto.fromYear())
|
|
||||||
.toYear(dto.toYear())
|
|
||||||
.notes(blankToNull(dto.notes()))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// saveAndFlush so the unique_rel constraint violates synchronously and is
|
|
||||||
// caught here, not at commit time outside the @Transactional boundary.
|
|
||||||
return toDTO(relationshipRepository.saveAndFlush(rel));
|
|
||||||
} catch (DataIntegrityViolationException e) {
|
|
||||||
throw DomainException.conflict(
|
|
||||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
|
||||||
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void deleteRelationship(UUID personId, UUID relId) {
|
|
||||||
PersonRelationship rel = relationshipRepository.findById(relId)
|
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
|
||||||
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
|
||||||
|
|
||||||
UUID storageSubject = rel.getPerson().getId();
|
|
||||||
UUID storageObject = rel.getRelatedPerson().getId();
|
|
||||||
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
|
||||||
throw DomainException.forbidden(
|
|
||||||
"Relationship " + relId + " does not belong to person " + personId);
|
|
||||||
}
|
|
||||||
relationshipRepository.delete(rel);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
|
||||||
return personService.setFamilyMember(personId, familyMember);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String blankToNull(String s) {
|
|
||||||
return (s == null || s.isBlank()) ? null : s.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
|
||||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
|
||||||
throw DomainException.badRequest(
|
|
||||||
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RelationshipDTO toDTO(PersonRelationship r) {
|
|
||||||
Person p = r.getPerson();
|
|
||||||
Person rp = r.getRelatedPerson();
|
|
||||||
return new RelationshipDTO(
|
|
||||||
r.getId(),
|
|
||||||
p.getId(),
|
|
||||||
rp.getId(),
|
|
||||||
p.getDisplayName(),
|
|
||||||
p.getBirthYear(),
|
|
||||||
p.getDeathYear(),
|
|
||||||
rp.getDisplayName(),
|
|
||||||
rp.getBirthYear(),
|
|
||||||
rp.getDeathYear(),
|
|
||||||
r.getRelationType(),
|
|
||||||
r.getFromYear(),
|
|
||||||
r.getToYear(),
|
|
||||||
r.getNotes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import org.raddatz.familienarchiv.relationship.RelationType;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record CreateRelationshipRequest(
|
|
||||||
@NotNull UUID relatedPersonId,
|
|
||||||
@NotNull RelationType relationType,
|
|
||||||
Integer fromYear,
|
|
||||||
Integer toYear,
|
|
||||||
@Size(max = 2000) String notes
|
|
||||||
) {}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship.dto;
|
|
||||||
|
|
||||||
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
|
|
||||||
public record FamilyMemberPatchDTO(boolean familyMember) {}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pairwise inferred relationship for the document badge.
|
|
||||||
* {@code labelFromA} reads "Person B, from A's point of view" and vice-versa
|
|
||||||
* (e.g. A=parent, B=child → labelFromA = "Sohn", labelFromB = "Vater").
|
|
||||||
*/
|
|
||||||
public record InferredRelationshipDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromA,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromB,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
|
|
||||||
) {}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used by {@code GET /api/persons/{id}/inferred-relationships}: each entry
|
|
||||||
* is a derived relationship to another family member, labelled from the
|
|
||||||
* requesting person's perspective.
|
|
||||||
*/
|
|
||||||
public record InferredRelationshipWithPersonDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) PersonNodeDTO person,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String label,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
|
|
||||||
) {}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** Payload for {@code GET /api/network}. Nodes are family members; edges are family-graph relationships. */
|
|
||||||
public record NetworkDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonNodeDTO> nodes,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<RelationshipDTO> edges
|
|
||||||
) {}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/** Lightweight node for the Stammbaum tree and inferred-relationship payloads. */
|
|
||||||
public record PersonNodeDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
|
||||||
Integer birthYear,
|
|
||||||
Integer deathYear,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
|
||||||
) {}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.relationship.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import org.raddatz.familienarchiv.relationship.RelationType;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire shape for one stored relationship row. Both sides include name + years
|
|
||||||
* so the frontend can render the row from either perspective (e.g. on the
|
|
||||||
* subject's page the row reads "Elternteil von [related]"; on the object's
|
|
||||||
* page it reads "Kind von [person]").
|
|
||||||
*
|
|
||||||
* <p>Storage truth: {@code personId} is the {@code person_id} column,
|
|
||||||
* {@code relatedPersonId} is the {@code related_person_id} column. The
|
|
||||||
* frontend determines orientation by comparing against the viewpoint.
|
|
||||||
*/
|
|
||||||
public record RelationshipDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID relatedPersonId,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String personDisplayName,
|
|
||||||
Integer personBirthYear,
|
|
||||||
Integer personDeathYear,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String relatedPersonDisplayName,
|
|
||||||
Integer relatedPersonBirthYear,
|
|
||||||
Integer relatedPersonDeathYear,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
|
||||||
Integer fromYear,
|
|
||||||
Integer toYear,
|
|
||||||
String notes
|
|
||||||
) {}
|
|
||||||
@@ -13,10 +13,11 @@ import java.util.UUID;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
||||||
|
Optional<AppUser> findByUsername(String username);
|
||||||
Optional<AppUser> findByEmail(String email);
|
Optional<AppUser> findByEmail(String email);
|
||||||
|
|
||||||
@Query("SELECT u FROM AppUser u WHERE " +
|
@Query("SELECT u FROM AppUser u WHERE " +
|
||||||
"LOWER(u.email) LIKE LOWER(CONCAT('%', :q, '%')) " +
|
"LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) " +
|
||||||
"OR LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%'))")
|
"OR LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))")
|
||||||
List<AppUser> searchByEmailOrName(@Param("q") String q, Pageable pageable);
|
List<AppUser> searchByNameOrUsername(@Param("q") String q, Pageable pageable);
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user