Compare commits
331 Commits
feat/issue
...
8c7f3b2e4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7f3b2e4e | ||
|
|
52178c2f5b | ||
|
|
54b4b96411 | ||
|
|
c905b81fd3 | ||
|
|
db66d0cc61 | ||
|
|
7dc5dc6f71 | ||
|
|
d974d39d17 | ||
|
|
5e4e487d5f | ||
|
|
b3fe9b1171 | ||
|
|
3c7c7a9aa4 | ||
|
|
9908f7afdc | ||
|
|
96d9ff5db1 | ||
|
|
0113367d05 | ||
|
|
fb6bffd7ee | ||
|
|
b087de84c4 | ||
|
|
3e07f6798c | ||
|
|
bc0824b934 | ||
|
|
7ccd541d40 | ||
|
|
835dc77382 | ||
|
|
37edac4da6 | ||
|
|
49443ad16a | ||
|
|
e6844c403c | ||
|
|
f1932fd5f6 | ||
|
|
ba88febc77 | ||
|
|
fa7b97acdc | ||
|
|
6ef888a128 | ||
|
|
94d0733412 | ||
|
|
4ac94b2feb | ||
|
|
392af640c4 | ||
|
|
7a25feb04e | ||
|
|
d87ad36278 | ||
|
|
39ddf90725 | ||
|
|
e5634c301e | ||
|
|
68cb6e9b76 | ||
|
|
5591f95871 | ||
|
|
41a57c0dc8 | ||
|
|
2d19ca7244 | ||
|
|
bc58d77f2c | ||
|
|
515fa03088 | ||
|
|
060a1149e0 | ||
|
|
558e1e6b22 | ||
|
|
6dd60571e3 | ||
|
|
3365f5845e | ||
|
|
3faac13533 | ||
|
|
5890bb3abd | ||
|
|
060db69108 | ||
|
|
1842e23c81 | ||
|
|
26519d029a | ||
|
|
488d4384a1 | ||
|
|
6a6967d841 | ||
|
|
ae868f4110 | ||
|
|
1fd38830fe | ||
|
|
c9c395eb59 | ||
|
|
c247e1e971 | ||
|
|
eb6e21f032 | ||
|
|
b4b46a0a79 | ||
|
|
ba73387d50 | ||
|
|
d9c7abf2ab | ||
|
|
7fc56022ae | ||
|
|
e8ba840560 | ||
|
|
09f71a2dce | ||
|
|
86ad5ca9b3 | ||
|
|
780c682136 | ||
|
|
a8a3b7f574 | ||
|
|
f0bb1c3163 | ||
|
|
cacbd57752 | ||
|
|
43aacd9f60 | ||
|
|
362a84dde9 | ||
|
|
49db82e1bd | ||
|
|
fd3a44d10c | ||
|
|
cb51e8e432 | ||
|
|
bbde9e8497 | ||
|
|
793496440c | ||
|
|
e3175f493c | ||
|
|
64a61f705c | ||
|
|
e50aab2578 | ||
|
|
02d3e2ab61 | ||
|
|
c4ee2c666b | ||
|
|
bf8fb00dd2 | ||
|
|
b3ce15f0dd | ||
|
|
c7013f4902 | ||
|
|
091f6c7592 | ||
|
|
3a6f90441e | ||
|
|
13e0801b30 | ||
|
|
4c3aa159c5 | ||
|
|
eb51155b4e | ||
|
|
43f474fc5b | ||
|
|
8ca3f37817 | ||
|
|
1dc812bd47 | ||
|
|
7a647b5633 | ||
|
|
5f76d4a1ac | ||
|
|
c7958681f5 | ||
|
|
1f3f879f9c | ||
|
|
7906373053 | ||
|
|
2d48821f95 | ||
|
|
0def9e9b9d | ||
|
|
acffcc8516 | ||
|
|
48492330a7 | ||
|
|
d924d9059c | ||
|
|
99aee777de | ||
|
|
8b498665df | ||
|
|
5ebe1f1a5a | ||
|
|
221a6af838 | ||
|
|
404d874b4e | ||
|
|
4bc4267e5a | ||
|
|
bd17532118 | ||
|
|
e021261300 | ||
|
|
e94ffde075 | ||
|
|
29a1df5d9c | ||
|
|
4d288589fa | ||
|
|
a2c633c5de | ||
|
|
28112e1d7b | ||
|
|
08e7987033 | ||
|
|
1db0f38f62 | ||
|
|
4e8df66a79 | ||
|
|
80ddfb47ac | ||
|
|
7805da52e6 | ||
|
|
0f3e000379 | ||
|
|
b435fd69f7 | ||
|
|
a6c8db226d | ||
|
|
e833d1f71a | ||
|
|
5d82a3e471 | ||
|
|
cb93f55396 | ||
|
|
3cfaae06da | ||
|
|
a81323a7a1 | ||
|
|
10b1bab57b | ||
|
|
000333d540 | ||
|
|
5817a79151 | ||
|
|
3b430828b7 | ||
|
|
f8aa8c6574 | ||
|
|
ce005622f2 | ||
|
|
0e9fa157e5 | ||
|
|
fa1dfbc99d | ||
|
|
eb91639a5e | ||
|
|
43fb51305e | ||
|
|
6babcc7f17 | ||
|
|
1754b96b18 | ||
|
|
d230156651 | ||
|
|
93f4a00032 | ||
|
|
ea97bdd869 | ||
|
|
cbaff016d0 | ||
|
|
0b3455dbb2 | ||
|
|
499d0a3ca8 | ||
|
|
bd3feda182 | ||
|
|
f2127e2814 | ||
|
|
13bb3b451e | ||
|
|
6074ac396f | ||
|
|
b6253cb023 | ||
|
|
e94e9a3573 | ||
|
|
06ecad5e74 | ||
|
|
fcfae8fb78 | ||
|
|
83de7ff673 | ||
|
|
48649e67f9 | ||
|
|
1d14c32c23 | ||
|
|
d27fed3c35 | ||
|
|
22752ac1ae | ||
|
|
7a3d919c2d | ||
|
|
b969bcd877 | ||
|
|
cd26057ea5 | ||
|
|
ccbcbca0e8 | ||
|
|
c40cc05f68 | ||
|
|
a021355072 | ||
|
|
8971fee75e | ||
|
|
48a704f002 | ||
|
|
a7b1dcb5e1 | ||
|
|
f382bd9974 | ||
|
|
d7f4f6f163 | ||
|
|
242e10179d | ||
|
|
aaf885cafd | ||
|
|
b658a13247 | ||
|
|
6bed617959 | ||
|
|
51db976348 | ||
|
|
fc46704144 | ||
|
|
050f2bc929 | ||
|
|
f29f4d3f5b | ||
|
|
790c6f5b02 | ||
|
|
acea4a60f2 | ||
|
|
25f62ce93b | ||
|
|
df6175ed2c | ||
| f6cf2e0e42 | |||
|
|
33ca2df45b | ||
|
|
0979302205 | ||
|
|
9fb2c025cf | ||
|
|
ee2de8135b | ||
|
|
fe13df574a | ||
|
|
a9080e9dab | ||
|
|
e8a1cc82ff | ||
|
|
5b18b87450 | ||
|
|
bfa8b9c147 | ||
|
|
3a94d62c74 | ||
|
|
163e99016a | ||
|
|
d6f3ca5c43 | ||
|
|
108edff8d2 | ||
|
|
3d3fe8d626 | ||
|
|
31e5573eab | ||
|
|
934a00feb3 | ||
|
|
be27489618 | ||
|
|
4e486a31cf | ||
|
|
2c5877ea9e | ||
|
|
cfbe33140c | ||
| e8d1835ae1 | |||
|
|
ce41e96a45 | ||
|
|
a6c8af0971 | ||
|
|
6d9910b805 | ||
|
|
1dd6e054fc | ||
|
|
23cff1cdd7 | ||
|
|
11d93919b2 | ||
|
|
f6bcc4f72a | ||
|
|
f4a4436eda | ||
|
|
1d3a3b3338 | ||
|
|
77affcfb4f | ||
|
|
36529f7e11 | ||
|
|
eb8f9d4dc4 | ||
|
|
a736b7399a | ||
|
|
e7c7f801c9 | ||
|
|
5062513ae6 | ||
|
|
24d5381775 | ||
|
|
826283afcb | ||
|
|
1d5f99a2c8 | ||
|
|
5961bfb916 | ||
|
|
4c300da65e | ||
|
|
bccff232fe | ||
|
|
327fd89cb9 | ||
|
|
23861055d1 | ||
|
|
2ddeb485e3 | ||
|
|
1f19fa3462 | ||
|
|
7ef1ab3b01 | ||
|
|
45db75bdf2 | ||
|
|
8870cbe2fe | ||
|
|
b4cf7f1b21 | ||
|
|
d5587d1b95 | ||
|
|
7699a4e7e2 | ||
|
|
110416d68b | ||
|
|
64fdc5b57e | ||
|
|
ac8d0d5796 | ||
|
|
b8dcb2d3f4 | ||
|
|
ecd531601a | ||
|
|
fe1101f9d5 | ||
|
|
928ebca056 | ||
|
|
5dd4a01995 | ||
|
|
f4132edc2b | ||
|
|
d952fab4cd | ||
|
|
d45739cb76 | ||
|
|
18cad798fc | ||
|
|
0ddf43947b | ||
|
|
45f7642f8d | ||
|
|
5a13e61357 | ||
|
|
a91ee1f26d | ||
|
|
c59287fcfc | ||
|
|
8ce96294b0 | ||
|
|
1803db86b5 | ||
|
|
46001bbf9d | ||
|
|
af8303dbf8 | ||
|
|
7df00859c6 | ||
|
|
92d623e298 | ||
|
|
156efe8b31 | ||
|
|
499beca124 | ||
|
|
5cbb14d4a3 | ||
|
|
2bb8fb8968 | ||
|
|
f13f635161 | ||
|
|
6d3489d035 | ||
|
|
fa5dc43864 | ||
|
|
d4f32ed5d4 | ||
|
|
27e3d290e7 | ||
|
|
25446c9a5c | ||
|
|
660e34e016 | ||
|
|
b662117e55 | ||
|
|
d251806e72 | ||
|
|
f0da033ec9 | ||
|
|
a59feec81a | ||
|
|
779ffaab55 | ||
|
|
b690c74ddf | ||
|
|
0797406f02 | ||
|
|
c94d2cec03 | ||
|
|
4da0bf71a0 | ||
|
|
da5d3c60b3 | ||
|
|
ed0d0bf331 | ||
|
|
899508f9ca | ||
|
|
d32e671e9d | ||
|
|
b61cfa081f | ||
|
|
d914385afc | ||
|
|
6cdfc1f6a3 | ||
|
|
ed6a2fb56f | ||
|
|
58545876cd | ||
|
|
687ebf495d | ||
|
|
bc10f2af06 | ||
|
|
0bfd342190 | ||
|
|
1973f88e56 | ||
|
|
9f044f429c | ||
|
|
7ad5e35fd6 | ||
|
|
e7afed5ac3 | ||
|
|
f48d1e3cd8 | ||
|
|
fc118f7032 | ||
|
|
4229e952fb | ||
|
|
e1259215ef | ||
|
|
f06d034b36 | ||
|
|
a6cd10f219 | ||
|
|
b8e6fe9ec9 | ||
|
|
763f1990cd | ||
|
|
ca62f50921 | ||
|
|
61f84a86ac | ||
|
|
0eb5c95c6c | ||
|
|
d662635392 | ||
|
|
b00be2548c | ||
|
|
01a8654347 | ||
|
|
c1b221412f | ||
|
|
76c14ea604 | ||
|
|
539842e849 | ||
|
|
ef7a51fe30 | ||
|
|
ec17cb123a | ||
|
|
801470093d | ||
|
|
af6ba6a9cc | ||
|
|
9acd5ec617 | ||
|
|
29a44b3cd1 | ||
|
|
5fe289b06b | ||
|
|
f76af8c678 | ||
|
|
69c739c6e3 | ||
|
|
43cf022f05 | ||
|
|
48d034dcb8 | ||
|
|
c335ddd686 | ||
|
|
7830a749a0 | ||
|
|
5b7c37391c | ||
|
|
ce72b07197 | ||
|
|
505804c893 | ||
|
|
67421a4c0c | ||
|
|
0ea0df4f72 | ||
|
|
077f5c85df | ||
|
|
018e272a3b | ||
|
|
0c4a0ead7b | ||
|
|
82b12d4383 | ||
|
|
01758e8e00 |
3
backend/api_tests/Transcription.http
Normal file
3
backend/api_tests/Transcription.http
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
### Mark all blocks as reviewed
|
||||||
|
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
|
||||||
|
Authorization: Basic admin admin123
|
||||||
@@ -26,7 +26,16 @@ public enum AuditKind {
|
|||||||
COMMENT_ADDED,
|
COMMENT_ADDED,
|
||||||
|
|
||||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
||||||
MENTION_CREATED;
|
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(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
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.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -197,4 +199,6 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
|||||||
ORDER BY ranked.document_id, ranked.rn
|
ORDER BY ranked.document_id, ranked.rn
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
||||||
|
|
||||||
|
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
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
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuditLogQueryService {
|
public class AuditLogQueryService {
|
||||||
@@ -51,6 +57,11 @@ public class AuditLogQueryService {
|
|||||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
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) {
|
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
||||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||||
for (ContributorRow row : rows) {
|
for (ContributorRow row : rows) {
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
||||||
|
boolean existsByKind(AuditKind kind);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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;
|
||||||
@@ -13,11 +14,18 @@ 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.Max;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.validation.annotation.Validated;
|
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.TagOperator;
|
||||||
@@ -193,6 +201,7 @@ public class DocumentController {
|
|||||||
@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) {
|
Authentication authentication) {
|
||||||
List<Document> created = new ArrayList<>();
|
List<Document> created = new ArrayList<>();
|
||||||
List<Document> updated = new ArrayList<>();
|
List<Document> updated = new ArrayList<>();
|
||||||
@@ -202,14 +211,21 @@ public class DocumentController {
|
|||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
documentService.validateBatch(files.size(), metadata);
|
||||||
|
|
||||||
UUID actorId = requireUserId(authentication);
|
UUID actorId = requireUserId(authentication);
|
||||||
for (MultipartFile file : files) {
|
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 = documentService.storeDocument(file, actorId);
|
DocumentService.StoreResult result = metadata != null
|
||||||
|
? 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 {
|
||||||
@@ -221,9 +237,107 @@ 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)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public Map<String, Long> getIncompleteCount() {
|
public Map<String, Long> getIncompleteCount() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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;
|
||||||
@@ -47,6 +48,12 @@ 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())
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -63,27 +65,33 @@ 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) {
|
||||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
validatePersonNames(dto);
|
||||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||||
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) {
|
||||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
validatePersonNames(dto);
|
||||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||||
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,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -45,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,
|
||||||
@RequestBody CreateTranscriptionBlockDTO dto,
|
@Valid @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);
|
||||||
@@ -56,7 +57,7 @@ public class TranscriptionBlockController {
|
|||||||
public TranscriptionBlock updateBlock(
|
public TranscriptionBlock updateBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId,
|
@PathVariable UUID blockId,
|
||||||
@RequestBody UpdateTranscriptionBlockDTO dto,
|
@Valid @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);
|
||||||
@@ -90,6 +91,15 @@ public class TranscriptionBlockController {
|
|||||||
return transcriptionService.reviewBlock(documentId, blockId, userId);
|
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")
|
||||||
@RequirePermission(Permission.READ_ALL)
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public List<TranscriptionBlockVersion> getBlockHistory(
|
public List<TranscriptionBlockVersion> getBlockHistory(
|
||||||
|
|||||||
@@ -78,24 +78,31 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/users")
|
@PostMapping("/users")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
|
public ResponseEntity<AppUser> createUser(Authentication authentication,
|
||||||
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
@Valid @RequestBody CreateUserRequest 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(@PathVariable UUID id,
|
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
|
||||||
|
@PathVariable UUID id,
|
||||||
@RequestBody AdminUpdateUserRequest dto) {
|
@RequestBody AdminUpdateUserRequest dto) {
|
||||||
AppUser updated = userService.adminUpdateUser(id, dto);
|
AppUser updated = userService.adminUpdateUser(actorId(authentication), 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(@PathVariable UUID id) {
|
public ResponseEntity<Void> deleteUser(Authentication authentication,
|
||||||
userService.deleteUser(id);
|
@PathVariable UUID 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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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,14 +1,21 @@
|
|||||||
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;
|
||||||
@@ -22,4 +29,8 @@ 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<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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) {}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ 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;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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,10 +1,14 @@
|
|||||||
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,13 +1,24 @@
|
|||||||
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,7 +13,8 @@ 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,
|
||||||
@@ -94,6 +95,14 @@ public enum ErrorCode {
|
|||||||
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
||||||
OCR_TRAINING_CONFLICT,
|
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,
|
||||||
|
|
||||||
// --- Tags ---
|
// --- Tags ---
|
||||||
/** A tag with the given ID does not exist. 404 */
|
/** A tag with the given ID does not exist. 404 */
|
||||||
TAG_NOT_FOUND,
|
TAG_NOT_FOUND,
|
||||||
@@ -109,6 +118,10 @@ public enum ErrorCode {
|
|||||||
// --- 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ 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
|
||||||
@@ -33,6 +35,16 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship.dto;
|
||||||
|
|
||||||
|
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
|
||||||
|
public record FamilyMemberPatchDTO(boolean familyMember) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
SELECT d.id FROM documents d
|
SELECT d.id FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('german', regexp_replace(
|
THEN to_tsquery('simple', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
FROM documents d
|
FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('german', regexp_replace(
|
THEN to_tsquery('simple', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
|
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
|
||||||
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
|
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
|
||||||
|
|
||||||
|
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||||
|
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
|
|
||||||
// Lookup by full alias string, used during ODS mass import
|
// Lookup by full alias string, used during ODS mass import
|
||||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
|
p.family_member AS familyMember,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -50,6 +54,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
|
p.family_member AS familyMember,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -58,7 +63,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes
|
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member
|
||||||
ORDER BY p.last_name ASC, p.first_name ASC
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
|||||||
|
|
||||||
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
|
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT DISTINCT b FROM TranscriptionBlock b
|
||||||
|
JOIN FETCH b.mentionedPersons
|
||||||
|
WHERE b.id IN (
|
||||||
|
SELECT bb.id FROM TranscriptionBlock bb JOIN bb.mentionedPersons m WHERE m.personId = :personId
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
List<TranscriptionBlock> findByPersonIdWithMentionsFetched(@Param("personId") UUID personId);
|
||||||
|
|
||||||
void deleteByAnnotationId(UUID annotationId);
|
void deleteByAnnotationId(UUID annotationId);
|
||||||
|
|
||||||
int countByDocumentId(UUID documentId);
|
int countByDocumentId(UUID documentId);
|
||||||
@@ -51,21 +60,25 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
|||||||
""")
|
""")
|
||||||
List<TranscriptionBlock> findSegmentationBlocks();
|
List<TranscriptionBlock> findSegmentationBlocks();
|
||||||
|
|
||||||
|
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
|
||||||
|
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT COUNT(b) FROM TranscriptionBlock b
|
SELECT COUNT(b) FROM TranscriptionBlock b
|
||||||
JOIN Document d ON d.id = b.documentId
|
JOIN Document d ON d.id = b.documentId
|
||||||
WHERE b.source = 'MANUAL'
|
WHERE b.source = 'MANUAL'
|
||||||
AND d.sender.id = :personId
|
AND d.sender.id = :personId
|
||||||
AND d.scriptType = 'HANDWRITING_KURRENT'
|
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
|
||||||
""")
|
""")
|
||||||
long countManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
long countManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
||||||
|
|
||||||
|
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
|
||||||
|
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT b FROM TranscriptionBlock b
|
SELECT b FROM TranscriptionBlock b
|
||||||
JOIN Document d ON d.id = b.documentId
|
JOIN Document d ON d.id = b.documentId
|
||||||
WHERE b.source = 'MANUAL'
|
WHERE b.source = 'MANUAL'
|
||||||
AND d.sender.id = :personId
|
AND d.sender.id = :personId
|
||||||
AND d.scriptType = 'HANDWRITING_KURRENT'
|
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
|
||||||
""")
|
""")
|
||||||
List<TranscriptionBlock> findManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
List<TranscriptionBlock> findManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
@@ -132,6 +135,52 @@ public class DocumentService {
|
|||||||
return new StoreResult(saved, isNew);
|
return new StoreResult(saved, isNew);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
||||||
|
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
|
||||||
|
if (fileCount > 50) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
||||||
|
}
|
||||||
|
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public StoreResult storeDocumentWithBatchMetadata(
|
||||||
|
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
|
||||||
|
StoreResult base = storeDocument(file, actorId);
|
||||||
|
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
|
||||||
|
return new StoreResult(documentRepository.save(doc), base.isNew());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
|
||||||
|
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
|
||||||
|
doc.setTitle(metadata.getTitles().get(fileIndex));
|
||||||
|
}
|
||||||
|
if (metadata.getSenderId() != null) {
|
||||||
|
doc.setSender(personService.getById(metadata.getSenderId()));
|
||||||
|
}
|
||||||
|
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
|
||||||
|
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
|
||||||
|
}
|
||||||
|
if (metadata.getDocumentDate() != null) {
|
||||||
|
doc.setDocumentDate(metadata.getDocumentDate());
|
||||||
|
}
|
||||||
|
if (metadata.getLocation() != null) {
|
||||||
|
doc.setLocation(metadata.getLocation());
|
||||||
|
}
|
||||||
|
if (metadata.getMetadataComplete() != null) {
|
||||||
|
doc.setMetadataComplete(metadata.getMetadataComplete());
|
||||||
|
}
|
||||||
|
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
|
||||||
|
UUID docId = doc.getId();
|
||||||
|
updateDocumentTags(docId, metadata.getTagNames());
|
||||||
|
doc = documentRepository.findById(docId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
||||||
String filename = (file != null && !file.isEmpty())
|
String filename = (file != null && !file.isEmpty())
|
||||||
@@ -222,6 +271,8 @@ public class DocumentService {
|
|||||||
doc.setTranscription(dto.getTranscription());
|
doc.setTranscription(dto.getTranscription());
|
||||||
doc.setSummary(dto.getSummary());
|
doc.setSummary(dto.getSummary());
|
||||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||||
|
doc.setArchiveBox(dto.getArchiveBox());
|
||||||
|
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||||
|
|
||||||
List<String> tags = new ArrayList<>();
|
List<String> tags = new ArrayList<>();
|
||||||
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
||||||
@@ -287,20 +338,143 @@ public class DocumentService {
|
|||||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||||
Document doc = documentRepository.findById(docId)
|
Document doc = documentRepository.findById(docId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||||
|
doc.setTags(resolveTags(tagNames));
|
||||||
|
return documentRepository.save(doc);
|
||||||
|
}
|
||||||
|
|
||||||
Set<Tag> newTags = new HashSet<>();
|
/**
|
||||||
|
* Resolves a list of tag-name strings to {@link Tag} entities, trimming
|
||||||
|
* whitespace and skipping blank entries. Single source of truth for
|
||||||
|
* "name string → Tag" so the find-or-create policy stays consistent
|
||||||
|
* across single-doc updates ({@link #updateDocumentTags}), bulk edits
|
||||||
|
* ({@link #applyBulkEditToDocument}), and the upload-batch path
|
||||||
|
* ({@code applyBatchMetadata}).
|
||||||
|
*/
|
||||||
|
private Set<Tag> resolveTags(List<String> tagNames) {
|
||||||
|
if (tagNames == null || tagNames.isEmpty()) return new HashSet<>();
|
||||||
|
Set<Tag> resolved = new HashSet<>();
|
||||||
for (String name : tagNames) {
|
for (String name : tagNames) {
|
||||||
// Clean the string
|
|
||||||
String cleanName = name.trim();
|
String cleanName = name.trim();
|
||||||
if (cleanName.isEmpty())
|
if (cleanName.isEmpty()) continue;
|
||||||
continue;
|
resolved.add(tagService.findOrCreate(cleanName));
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
newTags.add(tagService.findOrCreate(cleanName));
|
/**
|
||||||
|
* Returns all document IDs matching the given filter parameters, ignoring
|
||||||
|
* pagination. Used by the bulk-edit "Alle X editieren" fast path so the
|
||||||
|
* frontend can replace the selection with every match across pages in one
|
||||||
|
* round-trip.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||||
|
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
|
||||||
|
boolean hasText = StringUtils.hasText(text);
|
||||||
|
List<UUID> rankedIds = null;
|
||||||
|
if (hasText) {
|
||||||
|
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||||
|
if (rankedIds.isEmpty()) return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.setTags(newTags);
|
Specification<Document> spec = buildSearchSpec(
|
||||||
return documentRepository.save(doc);
|
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||||
|
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the search Specification chain. Shared by
|
||||||
|
* {@link #searchDocuments} (paged + sorted) and {@link #findIdsForFilter}
|
||||||
|
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
|
||||||
|
* full-text query returned no rows.
|
||||||
|
*/
|
||||||
|
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
|
||||||
|
LocalDate from, LocalDate to,
|
||||||
|
UUID sender, UUID receiver,
|
||||||
|
List<String> tags, String tagQ,
|
||||||
|
DocumentStatus status, TagOperator tagOperator) {
|
||||||
|
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||||
|
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||||
|
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
|
||||||
|
return Specification.where(textSpec)
|
||||||
|
.and(isBetween(from, to))
|
||||||
|
.and(hasSender(sender))
|
||||||
|
.and(hasReceiver(receiver))
|
||||||
|
.and(hasTags(expandedTagSets, useOrLogic))
|
||||||
|
.and(hasTagPartial(tagQ))
|
||||||
|
.and(hasStatus(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns lightweight summaries (id, title, server PDF URL) for the given
|
||||||
|
* document IDs. Unknown IDs are silently dropped — the consumer is the
|
||||||
|
* bulk-edit page's left strip, where missing previews would already be
|
||||||
|
* obvious; surfacing them as errors here adds no value.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<DocumentBatchSummary> batchMetadata(List<UUID> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) return List.of();
|
||||||
|
return documentRepository.findAllById(ids).stream()
|
||||||
|
.map(d -> new DocumentBatchSummary(
|
||||||
|
d.getId(),
|
||||||
|
d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
|
||||||
|
"/api/documents/" + d.getId() + "/file"))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a bulk-edit DTO to a single document atomically.
|
||||||
|
* Tags and receivers are additive (merged into existing sets); sender and the
|
||||||
|
* three location fields are replace-on-non-blank (null/blank means "no change").
|
||||||
|
* Wrapped in its own transaction so a failure on one document never partially
|
||||||
|
* mutates another in the controller's batch loop.
|
||||||
|
*
|
||||||
|
* Each successful update emits a {@link AuditKind#METADATA_UPDATED} audit
|
||||||
|
* event tagged {@code source=BULK_EDIT} and writes a row to
|
||||||
|
* {@code document_versions} so the family archive's "who changed what"
|
||||||
|
* trail stays complete across both single- and bulk-doc edit paths.
|
||||||
|
*
|
||||||
|
* NOTE on N+1: tag and person resolution happens per-document. With 500
|
||||||
|
* documents × 10 tags this fans out to ~5000 tag-resolve queries per
|
||||||
|
* request. Acceptable today because the family archive is bounded at
|
||||||
|
* ~1500 documents total. Tracked as a perf follow-up.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Document applyBulkEditToDocument(UUID id, DocumentBulkEditDTO dto, UUID actorId) {
|
||||||
|
Document doc = documentRepository.findById(id)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||||
|
|
||||||
|
if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
|
||||||
|
Set<Tag> merged = new HashSet<>(doc.getTags());
|
||||||
|
merged.addAll(resolveTags(dto.getTagNames()));
|
||||||
|
doc.setTags(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.getSenderId() != null) {
|
||||||
|
doc.setSender(personService.getById(dto.getSenderId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
|
||||||
|
Set<Person> merged = new HashSet<>(doc.getReceivers());
|
||||||
|
merged.addAll(personService.getAllById(dto.getReceiverIds()));
|
||||||
|
doc.setReceivers(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StringUtils.hasText(dto.getDocumentLocation())) {
|
||||||
|
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(dto.getArchiveBox())) {
|
||||||
|
doc.setArchiveBox(dto.getArchiveBox());
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(dto.getArchiveFolder())) {
|
||||||
|
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||||
|
}
|
||||||
|
|
||||||
|
Document saved = documentRepository.save(doc);
|
||||||
|
documentVersionService.recordVersion(saved);
|
||||||
|
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(),
|
||||||
|
Map.of("source", "BULK_EDIT"));
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -366,17 +540,8 @@ public class DocumentService {
|
|||||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
Specification<Document> spec = buildSearchSpec(
|
||||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||||
|
|
||||||
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
|
|
||||||
Specification<Document> spec = Specification.where(textSpec)
|
|
||||||
.and(isBetween(from, to))
|
|
||||||
.and(hasSender(sender))
|
|
||||||
.and(hasReceiver(receiver))
|
|
||||||
.and(hasTags(expandedTagSets, useOrLogic))
|
|
||||||
.and(hasTagPartial(tagQ))
|
|
||||||
.and(hasStatus(status));
|
|
||||||
|
|
||||||
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
||||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -58,6 +57,17 @@ public class PersonService {
|
|||||||
return personRepository.findAllById(ids);
|
return personRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Person> findAllFamilyMembers() {
|
||||||
|
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||||
|
Person person = getById(personId);
|
||||||
|
person.setFamilyMember(familyMember);
|
||||||
|
return personRepository.save(person);
|
||||||
|
}
|
||||||
|
|
||||||
public Optional<Person> findByName(String firstName, String lastName) {
|
public Optional<Person> findByName(String firstName, String lastName) {
|
||||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||||
}
|
}
|
||||||
@@ -109,8 +119,12 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person createPerson(PersonUpdateDTO dto) {
|
public Person createPerson(PersonUpdateDTO dto) {
|
||||||
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||||
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
|
.personType(dto.getPersonType())
|
||||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||||
.firstName(dto.getFirstName())
|
.firstName(dto.getFirstName())
|
||||||
.lastName(dto.getLastName())
|
.lastName(dto.getLastName())
|
||||||
@@ -136,9 +150,13 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||||
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
|
person.setPersonType(dto.getPersonType());
|
||||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||||
person.setFirstName(dto.getFirstName());
|
person.setFirstName(dto.getFirstName());
|
||||||
person.setLastName(dto.getLastName());
|
person.setLastName(dto.getLastName());
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ public class TranscriptionService {
|
|||||||
if (dto.getLabel() != null) {
|
if (dto.getLabel() != null) {
|
||||||
block.setLabel(dto.getLabel());
|
block.setLabel(dto.getLabel());
|
||||||
}
|
}
|
||||||
|
block.getMentionedPersons().clear();
|
||||||
|
block.getMentionedPersons().addAll(dto.getMentionedPersons());
|
||||||
block.setUpdatedBy(userId);
|
block.setUpdatedBy(userId);
|
||||||
|
|
||||||
TranscriptionBlock saved = blockRepository.save(block);
|
TranscriptionBlock saved = blockRepository.save(block);
|
||||||
@@ -205,6 +207,18 @@ public class TranscriptionService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
|
||||||
|
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
|
||||||
|
for (TranscriptionBlock block : blocks) {
|
||||||
|
if (!block.isReviewed()) {
|
||||||
|
block.setReviewed(true);
|
||||||
|
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blockRepository.saveAll(blocks);
|
||||||
|
}
|
||||||
|
|
||||||
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
||||||
getBlock(documentId, blockId);
|
getBlock(documentId, blockId);
|
||||||
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
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;
|
||||||
@@ -21,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.stream.Collectors.toSet;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -33,9 +38,10 @@ public class UserService {
|
|||||||
private final AppUserRepository userRepository;
|
private final AppUserRepository userRepository;
|
||||||
private final UserGroupRepository groupRepository;
|
private final UserGroupRepository groupRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
|
||||||
log.info("Creating or updating user: {}", request.getEmail());
|
log.info("Creating or updating user: {}", request.getEmail());
|
||||||
|
|
||||||
Set<UserGroup> groups = new HashSet<>();
|
Set<UserGroup> groups = new HashSet<>();
|
||||||
@@ -45,10 +51,12 @@ public class UserService {
|
|||||||
|
|
||||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||||
AppUser user;
|
AppUser user;
|
||||||
|
boolean isNew;
|
||||||
|
|
||||||
if (existingUser.isPresent()) {
|
if (existingUser.isPresent()) {
|
||||||
log.info("User exists, updating: {}", request.getEmail());
|
log.info("User exists, updating: {}", request.getEmail());
|
||||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||||
|
isNew = false;
|
||||||
} else {
|
} else {
|
||||||
log.info("Creating new user: {}", request.getEmail());
|
log.info("Creating new user: {}", request.getEmail());
|
||||||
user = AppUser.builder()
|
user = AppUser.builder()
|
||||||
@@ -61,8 +69,42 @@ public class UserService {
|
|||||||
.contact(request.getContact())
|
.contact(request.getContact())
|
||||||
.enabled(true)
|
.enabled(true)
|
||||||
.build();
|
.build();
|
||||||
|
isNew = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppUser saved = userRepository.save(user);
|
||||||
|
if (isNew) {
|
||||||
|
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
|
||||||
|
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AppUser createUserForBootstrap(CreateUserRequest request) {
|
||||||
|
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
|
||||||
|
|
||||||
|
Set<UserGroup> groups = new HashSet<>();
|
||||||
|
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||||
|
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||||
|
if (existingUser.isPresent()) {
|
||||||
|
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||||
|
return userRepository.save(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppUser user = AppUser.builder()
|
||||||
|
.email(request.getEmail())
|
||||||
|
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||||
|
.groups(groups)
|
||||||
|
.firstName(request.getFirstName())
|
||||||
|
.lastName(request.getLastName())
|
||||||
|
.birthDate(request.getBirthDate())
|
||||||
|
.contact(request.getContact())
|
||||||
|
.enabled(true)
|
||||||
|
.build();
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +136,13 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteUser(UUID userId) {
|
public void deleteUser(UUID actorId, UUID userId) {
|
||||||
AppUser user = userRepository.findById(userId)
|
AppUser user = userRepository.findById(userId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||||
|
String email = user.getEmail();
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
|
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
|
||||||
|
Map.of("userId", userId.toString(), "email", email));
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppUser getById(UUID id) {
|
public AppUser getById(UUID id) {
|
||||||
@@ -141,7 +186,7 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
|
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
|
||||||
AppUser user = getById(id);
|
AppUser user = getById(id);
|
||||||
|
|
||||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||||
@@ -166,13 +211,27 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.getGroupIds() != null) {
|
if (dto.getGroupIds() != null) {
|
||||||
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
Set<UserGroup> before = new HashSet<>(user.getGroups());
|
||||||
user.setGroups(groups);
|
Set<UserGroup> after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||||
|
user.setGroups(after);
|
||||||
|
groupChangePayload(before, after, id, user.getEmail())
|
||||||
|
.ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Optional<Map<String, Object>> groupChangePayload(
|
||||||
|
Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) {
|
||||||
|
Set<UUID> beforeIds = before.stream().map(UserGroup::getId).collect(toSet());
|
||||||
|
Set<UUID> afterIds = after.stream().map(UserGroup::getId).collect(toSet());
|
||||||
|
if (beforeIds.equals(afterIds)) return Optional.empty();
|
||||||
|
List<String> added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList();
|
||||||
|
List<String> removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList();
|
||||||
|
return Optional.of(Map.of("userId", userId.toString(), "email", email,
|
||||||
|
"addedGroups", added, "removedGroups", removed));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
||||||
AppUser user = getById(userId);
|
AppUser user = getById(userId);
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ spring:
|
|||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 50MB
|
max-file-size: 50MB
|
||||||
max-request-size: 50MB
|
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
|
||||||
|
file-size-threshold: 2KB
|
||||||
|
|
||||||
mail:
|
mail:
|
||||||
host: ${MAIL_HOST:}
|
host: ${MAIL_HOST:}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Family network: marks a Person as a tree node and stores typed relationships
|
||||||
|
-- between two persons. The tree page (/stammbaum) only shows persons with
|
||||||
|
-- family_member = TRUE. Symmetric types (SPOUSE_OF, SIBLING_OF) are stored once;
|
||||||
|
-- the partial unique index keeps SIBLING_OF pairs from being duplicated in the
|
||||||
|
-- reverse direction.
|
||||||
|
|
||||||
|
ALTER TABLE persons
|
||||||
|
ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE person_relationships (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
|
||||||
|
related_person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
|
||||||
|
relation_type VARCHAR(30) NOT NULL,
|
||||||
|
from_year INTEGER,
|
||||||
|
to_year INTEGER,
|
||||||
|
notes VARCHAR(2000),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT no_self_rel CHECK (person_id <> related_person_id),
|
||||||
|
CONSTRAINT unique_rel UNIQUE (person_id, related_person_id, relation_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_person_rel_person_id ON person_relationships(person_id);
|
||||||
|
CREATE INDEX idx_person_rel_related_person_id ON person_relationships(related_person_id);
|
||||||
|
|
||||||
|
-- Symmetric SIBLING_OF: enforce only one row per unordered pair.
|
||||||
|
CREATE UNIQUE INDEX unique_sibling_pair ON person_relationships (
|
||||||
|
LEAST(person_id, related_person_id),
|
||||||
|
GREATEST(person_id, related_person_id)
|
||||||
|
) WHERE relation_type = 'SIBLING_OF';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Symmetric SPOUSE_OF: enforce only one row per unordered pair, mirroring the
|
||||||
|
-- SIBLING_OF index added in V54.
|
||||||
|
CREATE UNIQUE INDEX unique_spouse_pair ON person_relationships (
|
||||||
|
LEAST(person_id, related_person_id),
|
||||||
|
GREATEST(person_id, related_person_id)
|
||||||
|
) WHERE relation_type = 'SPOUSE_OF';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Sidecar table for @-mentions inside transcription_blocks.text.
|
||||||
|
-- Each row is one (block_id, person_id, display_name) tuple emitted by the
|
||||||
|
-- typeahead in the transcription editor. block.text contains the literal
|
||||||
|
-- "@DisplayName" — the UUID lives only here so historical text stays clean.
|
||||||
|
--
|
||||||
|
-- Schema choice: child table via @ElementCollection (mirrors the established
|
||||||
|
-- UserGroup.permissions / group_permissions pattern), NOT JSONB. The "show
|
||||||
|
-- all blocks mentioning person X" query on the person detail page joins on
|
||||||
|
-- the indexed person_id column — equally fast as JSONB GIN containment, no
|
||||||
|
-- new dependency. document_comments.comment_mentions stays as a many-to-many
|
||||||
|
-- to AppUser; the divergence is intentional: Person mentions need lazy
|
||||||
|
-- degradation when a person is deleted (no FK), while user mentions don't.
|
||||||
|
--
|
||||||
|
-- No FK on person_id: when a Person is deleted we want @Auguste Raddatz to
|
||||||
|
-- remain visible as plain unlinked text inside the transcription rather than
|
||||||
|
-- vanishing or cascade-deleting the block.
|
||||||
|
|
||||||
|
CREATE TABLE transcription_block_mentioned_persons (
|
||||||
|
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
|
||||||
|
person_id UUID NOT NULL,
|
||||||
|
display_name VARCHAR(200) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tbmp_person_id ON transcription_block_mentioned_persons(person_id);
|
||||||
|
CREATE INDEX idx_tbmp_block_id ON transcription_block_mentioned_persons(block_id);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Prevent duplicate sidecar rows for the same (block, person) pair.
|
||||||
|
-- @ElementCollection uses DELETE+INSERT per update so normal JPA writes can't
|
||||||
|
-- create duplicates, but a raw-SQL import or concurrent bypass of JPA could.
|
||||||
|
ALTER TABLE transcription_block_mentioned_persons
|
||||||
|
ADD CONSTRAINT uq_tbmp_block_person UNIQUE (block_id, person_id);
|
||||||
@@ -6,12 +6,19 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -47,4 +54,21 @@ class AuditLogQueryServiceTest {
|
|||||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||||
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() {
|
||||||
|
AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build();
|
||||||
|
when(queryRepository.findByKindIn(anyCollection(), any(Pageable.class)))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(entry)));
|
||||||
|
|
||||||
|
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
|
||||||
|
|
||||||
|
assertThat(result).containsExactly(entry);
|
||||||
|
verify(queryRepository).findByKindIn(
|
||||||
|
argThat((Collection<AuditKind> kinds) ->
|
||||||
|
kinds.contains(AuditKind.USER_CREATED) &&
|
||||||
|
kinds.contains(AuditKind.USER_DELETED) &&
|
||||||
|
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
|
||||||
|
any(Pageable.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
|
import org.raddatz.familienarchiv.dto.GroupDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.concurrent.TimeUnit.SECONDS;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.awaitility.Awaitility.await;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class UserManagementAuditIntegrationTest {
|
||||||
|
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
@Autowired UserService userService;
|
||||||
|
@Autowired AppUserRepository userRepository;
|
||||||
|
@Autowired AuditLogRepository auditLogRepository;
|
||||||
|
@Autowired AuditLogQueryService auditLogQueryService;
|
||||||
|
@Autowired TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clearAuditLog() {
|
||||||
|
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndDeleteUser_producesOrderedAuditEntries() {
|
||||||
|
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
|
||||||
|
CreateUserRequest adminReq = new CreateUserRequest();
|
||||||
|
adminReq.setEmail("admin@test.example.com");
|
||||||
|
adminReq.setInitialPassword("admin-secret");
|
||||||
|
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq));
|
||||||
|
UUID actorId = actor.getId();
|
||||||
|
|
||||||
|
// Create the target user — should emit USER_CREATED
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setEmail("audit-test@example.com");
|
||||||
|
req.setInitialPassword("secret");
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
userService.createUserOrUpdate(actorId, req);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
||||||
|
|
||||||
|
// Delete the target user — should emit USER_DELETED
|
||||||
|
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
userService.deleteUser(actorId, created.getId());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
|
||||||
|
|
||||||
|
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
||||||
|
assertThat(events).hasSize(2);
|
||||||
|
assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED);
|
||||||
|
assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserGroups_producesGroupMembershipChangedEvent() {
|
||||||
|
GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL"));
|
||||||
|
GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL"));
|
||||||
|
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
|
||||||
|
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
|
||||||
|
|
||||||
|
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
|
||||||
|
CreateUserRequest actorReq = new CreateUserRequest();
|
||||||
|
actorReq.setEmail("actor-group-test@test.example.com");
|
||||||
|
actorReq.setInitialPassword("secret");
|
||||||
|
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq));
|
||||||
|
|
||||||
|
// Create target user pre-assigned to gA — emits USER_CREATED
|
||||||
|
CreateUserRequest targetReq = new CreateUserRequest();
|
||||||
|
targetReq.setEmail("target-group-test@test.example.com");
|
||||||
|
targetReq.setInitialPassword("secret");
|
||||||
|
targetReq.setGroupIds(List.of(gA.getId()));
|
||||||
|
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
|
||||||
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
||||||
|
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
||||||
|
|
||||||
|
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
|
||||||
|
|
||||||
|
// Change groups: Viewers → Editors
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setGroupIds(List.of(gB.getId()));
|
||||||
|
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
|
||||||
|
|
||||||
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
|
||||||
|
|
||||||
|
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
||||||
|
assertThat(events).hasSize(1);
|
||||||
|
AuditLog event = events.get(0);
|
||||||
|
assertThat(event.getKind()).isEqualTo(AuditKind.GROUP_MEMBERSHIP_CHANGED);
|
||||||
|
assertThat(event.getPayload()).containsEntry("email", "target-group-test@test.example.com");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> added = (List<String>) event.getPayload().get("addedGroups");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> removed = (List<String>) event.getPayload().get("removedGroups");
|
||||||
|
assertThat(added).containsExactlyInAnyOrder("Editors");
|
||||||
|
assertThat(removed).containsExactlyInAnyOrder("Viewers");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,13 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
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.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
@@ -766,4 +768,476 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
.andExpect(jsonPath("$.editorName").value("Otto"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||||
|
|
||||||
|
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
|
||||||
|
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
|
||||||
|
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
|
||||||
|
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc1, true));
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc2, true));
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc3, true));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile f1 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f2 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f3 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created.length()").value(3))
|
||||||
|
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||||
|
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
|
||||||
|
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
|
||||||
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||||
|
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
|
||||||
|
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(existing, false));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
|
||||||
|
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||||
|
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||||
|
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
|
||||||
|
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(docA, true));
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(docB, true));
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(docC, true));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile f1 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f2 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f3 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||||
|
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||||
|
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
org.mockito.Mockito.doThrow(
|
||||||
|
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
|
||||||
|
.when(documentService).validateBatch(eq(2), any());
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile f1 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile f2 =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
|
||||||
|
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
|
||||||
|
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc, true));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
|
||||||
|
org.springframework.mock.web.MockMultipartFile metadata =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
|
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||||
|
.containsExactly("Briefwechsel", "Krieg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
org.mockito.Mockito.doThrow(
|
||||||
|
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
|
||||||
|
.when(documentService).validateBatch(eq(51), any());
|
||||||
|
|
||||||
|
var builder = multipart("/api/documents/quick-upload");
|
||||||
|
for (int i = 0; i < 51; i++) {
|
||||||
|
builder.file(new org.springframework.mock.web.MockMultipartFile(
|
||||||
|
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.perform(builder)
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/documents/bulk ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String bulkBody(String... uuids) {
|
||||||
|
StringBuilder sb = new StringBuilder("{\"documentIds\":[");
|
||||||
|
for (int i = 0; i < uuids.length; i++) {
|
||||||
|
if (i > 0) sb.append(",");
|
||||||
|
sb.append("\"").append(uuids[i]).append("\"");
|
||||||
|
}
|
||||||
|
sb.append("]}");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void patchBulk_returns403_forReadAllUser() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"documentIds\":[]}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
|
String[] ids = new String[501];
|
||||||
|
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(ids)))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returns400_whenArchiveBoxExceeds255Chars() throws Exception {
|
||||||
|
// Tobias C2 — DocumentBulkEditDTO.archiveBox carries @Size(max=255).
|
||||||
|
// Without @Valid on @RequestBody this would silently land an
|
||||||
|
// arbitrarily long string; the test pins both the annotation and
|
||||||
|
// the controller-level @Valid wiring.
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
String tooLong = "x".repeat(256);
|
||||||
|
|
||||||
|
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||||
|
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||||
|
|
||||||
|
String[] ids = new String[500];
|
||||||
|
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(ids)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.updated").value(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(documentService.applyBulkEditToDocument(eq(id), any(), any()))
|
||||||
|
.thenAnswer(inv -> Document.builder().id(id).build());
|
||||||
|
|
||||||
|
// Same id sent three times — controller should dedupe and call the
|
||||||
|
// service exactly once, returning updated=1, not 3.
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.updated").value(1));
|
||||||
|
|
||||||
|
verify(documentService, org.mockito.Mockito.times(1))
|
||||||
|
.applyBulkEditToDocument(eq(id), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||||
|
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(id1.toString(), id2.toString())))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.updated").value(2))
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
|
||||||
|
verify(documentService).applyBulkEditToDocument(eq(id1), any(), any());
|
||||||
|
verify(documentService).applyBulkEditToDocument(eq(id2), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/ids ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDocumentIds_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/ids"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception {
|
||||||
|
// /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X
|
||||||
|
// editieren" fast path; no other consumer needs it.
|
||||||
|
mockMvc.perform(get("/api/documents/ids"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
|
.thenReturn(List.of(id));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/ids"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0]").value(id.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void getDocumentIds_passesSenderIdParamToService() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
|
||||||
|
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
|
||||||
|
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
|
||||||
|
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
|
.thenReturn(tooMany);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/ids"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"ids\":[]}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void batchMetadata_returns400_whenIdsExceedsCap() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
StringBuilder sb = new StringBuilder("{\"ids\":[");
|
||||||
|
for (int i = 0; i < 501; i++) {
|
||||||
|
if (i > 0) sb.append(",");
|
||||||
|
sb.append("\"").append(UUID.randomUUID()).append("\"");
|
||||||
|
}
|
||||||
|
sb.append("]}");
|
||||||
|
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(sb.toString()))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(documentService.batchMetadata(any())).thenReturn(List.of(
|
||||||
|
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
|
||||||
|
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"ids\":[\"" + id + "\"]}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||||
|
.andExpect(jsonPath("$[0].title").value("Brief"))
|
||||||
|
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages() throws Exception {
|
||||||
|
// Nora C4 — DocumentController.sanitizeForLog defends against
|
||||||
|
// CWE-117 (log injection) by replacing CR/LF in any free-form string
|
||||||
|
// it interpolates. Same helper now sanitises BulkEditError.message
|
||||||
|
// before it round-trips to the frontend.
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID badId = UUID.randomUUID();
|
||||||
|
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
|
||||||
|
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
||||||
|
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(badId.toString())))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.errors[0].message",
|
||||||
|
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\n"))))
|
||||||
|
.andExpect(jsonPath("$.errors[0].message",
|
||||||
|
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\r"))))
|
||||||
|
.andExpect(jsonPath("$.errors[0].message",
|
||||||
|
org.hamcrest.Matchers.containsString("evil_")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID okId = UUID.randomUUID();
|
||||||
|
UUID badId = UUID.randomUUID();
|
||||||
|
when(documentService.applyBulkEditToDocument(eq(okId), any(), any()))
|
||||||
|
.thenAnswer(inv -> Document.builder().id(okId).build());
|
||||||
|
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
|
||||||
|
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(okId.toString(), badId.toString())))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.updated").value(1))
|
||||||
|
.andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
|
||||||
|
.andExpect(jsonPath("$.errors[0].message").value(
|
||||||
|
org.hamcrest.Matchers.containsString("not found")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
@@ -25,6 +28,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -53,6 +57,13 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
|
void getPersons_returns403_whenMissingReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_returns200_withEmptyList() throws Exception {
|
void getPersons_returns200_withEmptyList() throws Exception {
|
||||||
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
||||||
mockMvc.perform(get("/api/persons"))
|
mockMvc.perform(get("/api/persons"))
|
||||||
@@ -60,7 +71,7 @@ class PersonControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||||
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||||
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||||
@@ -81,6 +92,7 @@ class PersonControllerTest {
|
|||||||
public Integer getBirthYear() { return null; }
|
public Integer getBirthYear() { return null; }
|
||||||
public Integer getDeathYear() { return null; }
|
public Integer getDeathYear() { return null; }
|
||||||
public String getNotes() { return null; }
|
public String getNotes() { return null; }
|
||||||
|
public boolean isFamilyMember() { return false; }
|
||||||
public long getDocumentCount() { return 0; }
|
public long getDocumentCount() { return 0; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -95,6 +107,13 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
|
void getPerson_returns403_whenMissingReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPerson_returns200_whenFound() throws Exception {
|
void getPerson_returns200_whenFound() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
||||||
@@ -183,19 +202,19 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +223,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +232,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,11 +244,53 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Hans"));
|
.andExpect(jsonPath("$.firstName").value("Hans"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||||
|
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_trimsTitle_beforePersisting() throws Exception {
|
||||||
|
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
|
||||||
|
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
|
||||||
|
when(personService.createPerson(any())).thenThrow(
|
||||||
|
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -242,10 +303,10 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +315,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +328,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||||
}
|
}
|
||||||
@@ -317,11 +378,10 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
// firstName valid, lastName blank → second || operand = true → 400
|
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +399,7 @@ class PersonControllerTest {
|
|||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||||
"\"notes\":\"Some notes\"}"))
|
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||||
@@ -355,7 +415,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +426,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +437,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +446,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,36 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
String longName = "A".repeat(201);
|
||||||
|
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||||
|
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
||||||
|
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
|
mockMvc.perform(post(URL_BASE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||||
|
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
|
mockMvc.perform(post(URL_BASE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
|
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -221,6 +251,34 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(jsonPath("$.label").value("Anrede"));
|
.andExpect(jsonPath("$.label").value("Anrede"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updateBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
String longName = "A".repeat(201);
|
||||||
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
||||||
|
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updateBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
|
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
|
||||||
@@ -260,6 +318,13 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete(URL_BLOCK))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
||||||
@@ -373,4 +438,63 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.reviewed").value(true));
|
.andExpect(jsonPath("$.reviewed").value(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
TranscriptionBlock b1 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||||
|
.text("Block 1").sortOrder(0).reviewed(true).build();
|
||||||
|
TranscriptionBlock b2 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||||
|
.text("Block 2").sortOrder(1).reviewed(true).build();
|
||||||
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
|
.thenReturn(List.of(b1, b2));
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||||
|
.andExpect(jsonPath("$[1].reviewed").value(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -104,4 +106,55 @@ class UserControllerTest {
|
|||||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── permission enforcement ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "reader@example.com")
|
||||||
|
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users")
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "reader@example.com")
|
||||||
|
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "reader@example.com")
|
||||||
|
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── unauthenticated access ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users")
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
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.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doNothing;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@WebMvcTest(RelationshipController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class RelationshipControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean RelationshipService relationshipService;
|
||||||
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
private static final UUID PERSON_ID = UUID.randomUUID();
|
||||||
|
private static final UUID OTHER_ID = UUID.randomUUID();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getRelationshipBetween_returns404_with_RELATIONSHIP_NOT_FOUND_code_when_no_path() throws Exception {
|
||||||
|
when(relationshipService.getRelationshipBetween(any(), any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons/{aId}/relationship-to/{bId}", PERSON_ID, OTHER_ID))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.RELATIONSHIP_NOT_FOUND.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRelationships_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/relationships", PERSON_ID))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNetwork_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/network"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"familyMember\":true}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
|
||||||
|
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true);
|
||||||
|
RelationshipDTO edge = new RelationshipDTO(
|
||||||
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
|
"Alice Müller", 1900, 1980,
|
||||||
|
"Bob Müller", 1930, null,
|
||||||
|
RelationType.PARENT_OF, null, null, null);
|
||||||
|
when(relationshipService.getFamilyNetwork())
|
||||||
|
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/network"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.nodes[0].displayName").value("Alice Müller"))
|
||||||
|
.andExpect(jsonPath("$.edges[0].relationType").value("PARENT_OF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
|
||||||
|
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true);
|
||||||
|
InferredRelationshipWithPersonDTO inferred =
|
||||||
|
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
|
||||||
|
when(relationshipService.getInferredRelationships(PERSON_ID))
|
||||||
|
.thenReturn(List.of(inferred));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/inferred-relationships", PERSON_ID))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].label").value("Großvater"))
|
||||||
|
.andExpect(jsonPath("$[0].hops").value(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void addRelationship_returns201_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
|
||||||
|
RelationshipDTO created = new RelationshipDTO(
|
||||||
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
|
"Alice Müller", null, null,
|
||||||
|
"Bob Müller", null, null,
|
||||||
|
RelationType.PARENT_OF, null, null, null);
|
||||||
|
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.relationType").value("PARENT_OF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void deleteRelationship_returns204_for_WRITE_ALL_user() throws Exception {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
doNothing().when(relationshipService).deleteRelationship(any(), any());
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.Collections.emptyList;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.raddatz.familienarchiv.relationship.RelationToken.*;
|
||||||
|
import static org.raddatz.familienarchiv.relationship.RelationType.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felix Brandt — TDD red phase for RelationshipInferenceService.
|
||||||
|
* <p>
|
||||||
|
* 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires
|
||||||
|
* a small graph through the mocked repository and asserts the exact abstract
|
||||||
|
* token sequence emitted by BFS — except {@code distant_label_for_long_chain}
|
||||||
|
* which asserts the fallback label, and {@code returns_empty_when_no_path}
|
||||||
|
* which asserts no result.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class RelationshipInferenceServiceTest {
|
||||||
|
|
||||||
|
@Mock PersonRelationshipRepository relationshipRepository;
|
||||||
|
@Mock PersonService personService;
|
||||||
|
@InjectMocks RelationshipInferenceService service;
|
||||||
|
|
||||||
|
// --- 1: parent ---
|
||||||
|
@Test
|
||||||
|
void parent_path_emits_UP() {
|
||||||
|
Person parent = person();
|
||||||
|
Person child = person();
|
||||||
|
givenEdges(parentOf(parent, child));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(child.getId(), parent.getId()))
|
||||||
|
.hasValue(List.of(UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2: child ---
|
||||||
|
@Test
|
||||||
|
void child_path_emits_DOWN() {
|
||||||
|
Person parent = person();
|
||||||
|
Person child = person();
|
||||||
|
givenEdges(parentOf(parent, child));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(parent.getId(), child.getId()))
|
||||||
|
.hasValue(List.of(DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3: spouse ---
|
||||||
|
@Test
|
||||||
|
void spouse_path_emits_SPOUSE() {
|
||||||
|
Person a = person();
|
||||||
|
Person b = person();
|
||||||
|
givenEdges(spouseOf(a, b));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(a.getId(), b.getId()))
|
||||||
|
.hasValue(List.of(SPOUSE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4: sibling ---
|
||||||
|
@Test
|
||||||
|
void sibling_path_emits_SIBLING() {
|
||||||
|
Person a = person();
|
||||||
|
Person b = person();
|
||||||
|
givenEdges(siblingOf(a, b));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(a.getId(), b.getId()))
|
||||||
|
.hasValue(List.of(SIBLING));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5: grandparent (UP, UP) ---
|
||||||
|
@Test
|
||||||
|
void grandparent_path_emits_UP_UP() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person parent = person();
|
||||||
|
Person grandchild = person();
|
||||||
|
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId()))
|
||||||
|
.hasValue(List.of(UP, UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6: grandchild (DOWN, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void grandchild_path_emits_DOWN_DOWN() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person parent = person();
|
||||||
|
Person grandchild = person();
|
||||||
|
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId()))
|
||||||
|
.hasValue(List.of(DOWN, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 7: great-grandparent (UP, UP, UP) ---
|
||||||
|
@Test
|
||||||
|
void great_grandparent_path_emits_UP_UP_UP() {
|
||||||
|
Person g = person();
|
||||||
|
Person p = person();
|
||||||
|
Person c = person();
|
||||||
|
Person gc = person();
|
||||||
|
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(gc.getId(), g.getId()))
|
||||||
|
.hasValue(List.of(UP, UP, UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 8: great-grandchild (DOWN, DOWN, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void great_grandchild_path_emits_DOWN_DOWN_DOWN() {
|
||||||
|
Person g = person();
|
||||||
|
Person p = person();
|
||||||
|
Person c = person();
|
||||||
|
Person gc = person();
|
||||||
|
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(g.getId(), gc.getId()))
|
||||||
|
.hasValue(List.of(DOWN, DOWN, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 9: uncle/aunt (UP, SIBLING) ---
|
||||||
|
@Test
|
||||||
|
void uncle_aunt_path_emits_UP_SIBLING() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person parent = person();
|
||||||
|
Person uncle = person();
|
||||||
|
Person me = person();
|
||||||
|
// grandparent has two children: parent and uncle. me is parent's child.
|
||||||
|
givenEdges(
|
||||||
|
parentOf(grandparent, parent),
|
||||||
|
parentOf(grandparent, uncle),
|
||||||
|
parentOf(parent, me));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), uncle.getId()))
|
||||||
|
.hasValue(List.of(UP, SIBLING));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 10: niece/nephew (SIBLING, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void niece_nephew_path_emits_SIBLING_DOWN() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person uncle = person();
|
||||||
|
Person sibling = person();
|
||||||
|
Person niece = person();
|
||||||
|
// grandparent has uncle + sibling; sibling has niece.
|
||||||
|
givenEdges(
|
||||||
|
parentOf(grandparent, uncle),
|
||||||
|
parentOf(grandparent, sibling),
|
||||||
|
parentOf(sibling, niece));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(uncle.getId(), niece.getId()))
|
||||||
|
.hasValue(List.of(SIBLING, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 11: great uncle/aunt (UP, UP, SIBLING) ---
|
||||||
|
@Test
|
||||||
|
void great_uncle_aunt_path_emits_UP_UP_SIBLING() {
|
||||||
|
Person ggp = person();
|
||||||
|
Person grandparent = person();
|
||||||
|
Person greatUncle = person();
|
||||||
|
Person parent = person();
|
||||||
|
Person me = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(ggp, grandparent),
|
||||||
|
parentOf(ggp, greatUncle),
|
||||||
|
parentOf(grandparent, parent),
|
||||||
|
parentOf(parent, me));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), greatUncle.getId()))
|
||||||
|
.hasValue(List.of(UP, UP, SIBLING));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 12: great niece/nephew (SIBLING, DOWN, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person sibling = person();
|
||||||
|
Person greatUncle = person();
|
||||||
|
Person niece = person();
|
||||||
|
Person greatNiece = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(grandparent, sibling),
|
||||||
|
parentOf(grandparent, greatUncle),
|
||||||
|
parentOf(sibling, niece),
|
||||||
|
parentOf(niece, greatNiece));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId()))
|
||||||
|
.hasValue(List.of(SIBLING, DOWN, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 13: parent-in-law (SPOUSE, UP) ---
|
||||||
|
@Test
|
||||||
|
void inlaw_parent_path_emits_SPOUSE_UP() {
|
||||||
|
Person inlaw = person();
|
||||||
|
Person spouse = person();
|
||||||
|
Person me = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(inlaw, spouse),
|
||||||
|
spouseOf(me, spouse));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), inlaw.getId()))
|
||||||
|
.hasValue(List.of(SPOUSE, UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 14: child-in-law (DOWN, SPOUSE) ---
|
||||||
|
@Test
|
||||||
|
void inlaw_child_path_emits_DOWN_SPOUSE() {
|
||||||
|
Person me = person();
|
||||||
|
Person child = person();
|
||||||
|
Person inlawChild = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(me, child),
|
||||||
|
spouseOf(child, inlawChild));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), inlawChild.getId()))
|
||||||
|
.hasValue(List.of(DOWN, SPOUSE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) ---
|
||||||
|
@Test
|
||||||
|
void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() {
|
||||||
|
Person me = person();
|
||||||
|
Person spouse = person();
|
||||||
|
Person spouseSibling = person();
|
||||||
|
givenEdges(
|
||||||
|
spouseOf(me, spouse),
|
||||||
|
siblingOf(spouse, spouseSibling));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), spouseSibling.getId()))
|
||||||
|
.hasValue(List.of(SPOUSE, SIBLING));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 16: cousin (UP, SIBLING, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void cousin_1_path_emits_UP_SIBLING_DOWN() {
|
||||||
|
Person ggp = person();
|
||||||
|
Person parentMine = person();
|
||||||
|
Person uncle = person();
|
||||||
|
Person me = person();
|
||||||
|
Person cousin = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(ggp, parentMine),
|
||||||
|
parentOf(ggp, uncle),
|
||||||
|
parentOf(parentMine, me),
|
||||||
|
parentOf(uncle, cousin));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), cousin.getId()))
|
||||||
|
.hasValue(List.of(UP, SIBLING, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 17: distant (label fallback for long chains) ---
|
||||||
|
@Test
|
||||||
|
void distant_label_for_long_chain() {
|
||||||
|
// Seven-generation ancestor: chain of seven PARENT_OF edges.
|
||||||
|
Person a0 = person();
|
||||||
|
Person a1 = person();
|
||||||
|
Person a2 = person();
|
||||||
|
Person a3 = person();
|
||||||
|
Person a4 = person();
|
||||||
|
Person a5 = person();
|
||||||
|
Person a6 = person();
|
||||||
|
Person a7 = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(a0, a1),
|
||||||
|
parentOf(a1, a2),
|
||||||
|
parentOf(a2, a3),
|
||||||
|
parentOf(a3, a4),
|
||||||
|
parentOf(a4, a5),
|
||||||
|
parentOf(a5, a6),
|
||||||
|
parentOf(a6, a7));
|
||||||
|
|
||||||
|
Optional<InferredRelationshipDTO> inferred = service.infer(a7.getId(), a0.getId());
|
||||||
|
assertThat(inferred).hasValueSatisfying(r -> {
|
||||||
|
assertThat(r.hops()).isEqualTo(7);
|
||||||
|
assertThat(r.labelFromA()).isEqualTo("distant");
|
||||||
|
assertThat(r.labelFromB()).isEqualTo("distant");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 18: no path ---
|
||||||
|
@Test
|
||||||
|
void returns_empty_when_no_path() {
|
||||||
|
Person a = person();
|
||||||
|
Person b = person();
|
||||||
|
// No edges between them.
|
||||||
|
givenEdges(/* none */);
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty();
|
||||||
|
assertThat(service.infer(a.getId(), b.getId())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 19: findAllFor delegates person resolution to PersonService ---
|
||||||
|
@Test
|
||||||
|
void findAllFor_resolves_persons_via_PersonService() {
|
||||||
|
Person parent = person();
|
||||||
|
Person child = person();
|
||||||
|
givenEdges(parentOf(parent, child));
|
||||||
|
when(personService.getAllById(anyList())).thenReturn(List.of(child));
|
||||||
|
|
||||||
|
List<InferredRelationshipWithPersonDTO> results = service.findAllFor(parent.getId());
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0).person().displayName()).isEqualTo(child.getDisplayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private void givenEdges(PersonRelationship... edges) {
|
||||||
|
when(relationshipRepository.findAllByRelationTypeIn(anyCollection()))
|
||||||
|
.thenReturn(edges.length == 0 ? emptyList() : List.of(edges));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Person person() {
|
||||||
|
return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship parentOf(Person parent, Person child) {
|
||||||
|
return edge(parent, child, PARENT_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship spouseOf(Person a, Person b) {
|
||||||
|
return edge(a, b, SPOUSE_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship siblingOf(Person a, Person b) {
|
||||||
|
return edge(a, b, SIBLING_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship edge(Person a, Person b, RelationType type) {
|
||||||
|
return PersonRelationship.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.person(a)
|
||||||
|
.relatedPerson(b)
|
||||||
|
.relationType(type)
|
||||||
|
.createdAt(Instant.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
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.NetworkDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sara blocker 1 — service+DB integration over the family-network constraints.
|
||||||
|
* Hits the real Postgres so unique_rel, ON DELETE CASCADE, and the partial
|
||||||
|
* sibling index actually fire.
|
||||||
|
*/
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({
|
||||||
|
PostgresContainerConfig.class,
|
||||||
|
FlywayConfig.class,
|
||||||
|
RelationshipService.class,
|
||||||
|
RelationshipInferenceService.class,
|
||||||
|
PersonService.class
|
||||||
|
})
|
||||||
|
class RelationshipServiceIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired RelationshipService relationshipService;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
|
@Autowired PersonRelationshipRepository relationshipRepository;
|
||||||
|
// PersonService → PersonNameAliasRepository; @DataJpaTest auto-loads it.
|
||||||
|
@Autowired PersonNameAliasRepository aliasRepository;
|
||||||
|
@Autowired EntityManager entityManager;
|
||||||
|
|
||||||
|
Person alice;
|
||||||
|
Person bob;
|
||||||
|
Person charlie;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
relationshipRepository.deleteAll();
|
||||||
|
aliasRepository.deleteAll();
|
||||||
|
personRepository.deleteAll();
|
||||||
|
alice = personRepository.save(Person.builder().firstName("Alice").lastName("Müller").familyMember(true).build());
|
||||||
|
bob = personRepository.save(Person.builder().firstName("Bob").lastName("Müller").familyMember(true).build());
|
||||||
|
charlie = personRepository.save(Person.builder().firstName("Charlie").lastName("Schmidt").familyMember(false).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_stores_and_is_readable() {
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
|
||||||
|
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
|
assertThat(created.id()).isNotNull();
|
||||||
|
assertThat(created.personId()).isEqualTo(alice.getId());
|
||||||
|
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
|
|
||||||
|
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
||||||
|
assertThat(rels).hasSize(1);
|
||||||
|
assertThat(rels.get(0).relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_409_when_duplicate() {
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
|
relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_409_when_circular_parent() {
|
||||||
|
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
||||||
|
relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
|
||||||
|
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
|
||||||
|
// Charlie is unrelated to this row.
|
||||||
|
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||||
|
|
||||||
|
// The row is still there.
|
||||||
|
assertThat(relationshipRepository.findById(created.id())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
||||||
|
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
||||||
|
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
||||||
|
relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||||
|
|
||||||
|
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
|
||||||
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
||||||
|
// alice SPOUSE_OF bob. Bob deletes from his side.
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||||
|
|
||||||
|
relationshipService.deleteRelationship(bob.getId(), created.id());
|
||||||
|
|
||||||
|
assertThat(relationshipRepository.findById(created.id())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setFamilyMember_true_makes_person_appear_in_network() {
|
||||||
|
// charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie
|
||||||
|
// so the edge exists, then flip charlie's flag and verify he appears in nodes.
|
||||||
|
relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
|
||||||
|
NetworkDTO before = relationshipService.getFamilyNetwork();
|
||||||
|
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
|
||||||
|
|
||||||
|
relationshipService.setFamilyMember(charlie.getId(), true);
|
||||||
|
|
||||||
|
NetworkDTO after = relationshipService.getFamilyNetwork();
|
||||||
|
assertThat(after.nodes()).extracting("id").contains(charlie.getId());
|
||||||
|
assertThat(after.edges())
|
||||||
|
.anyMatch(e -> e.personId().equals(alice.getId()) && e.relatedPersonId().equals(charlie.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_person_cascades_to_relationships() {
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
UUID relId = created.id();
|
||||||
|
assertThat(relationshipRepository.findById(relId)).isPresent();
|
||||||
|
|
||||||
|
// Detach managed entities so deleteById's cascade isn't fought by the
|
||||||
|
// persistence context (the rel row still references bob in memory).
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
// Delete bob (the relatedPerson) — DB CASCADE must remove the row.
|
||||||
|
personRepository.deleteById(bob.getId());
|
||||||
|
personRepository.flush();
|
||||||
|
|
||||||
|
assertThat(relationshipRepository.findById(relId)).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||||
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felix Brandt — TDD red for RelationshipService domain rules.
|
||||||
|
*
|
||||||
|
* <p>Required by the plan (Nora blockers 1 + 2):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}</li>
|
||||||
|
* <li>{@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}</li>
|
||||||
|
* </ul>
|
||||||
|
* Plus: duplicate constraint, self-relationship, year-range, happy-path persistence,
|
||||||
|
* and ownership permitted from either side.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class RelationshipServiceTest {
|
||||||
|
|
||||||
|
@Mock PersonRelationshipRepository relationshipRepository;
|
||||||
|
@Mock PersonService personService;
|
||||||
|
@Mock RelationshipInferenceService inferenceService;
|
||||||
|
@InjectMocks RelationshipService service;
|
||||||
|
|
||||||
|
Person alice;
|
||||||
|
Person bob;
|
||||||
|
Person charlie;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
alice = person("Alice");
|
||||||
|
bob = person("Bob");
|
||||||
|
charlie = person("Charlie");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Nora blocker 1 ---
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||||
|
verify(relationshipRepository, never()).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Nora blocker 2 ---
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
|
||||||
|
// alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice.
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||||
|
|
||||||
|
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_DUPLICATE_when_db_constraint_violated() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||||
|
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
||||||
|
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_persists_with_storage_truth() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
|
||||||
|
PersonRelationship r = inv.getArgument(0);
|
||||||
|
r.setId(UUID.randomUUID());
|
||||||
|
r.setCreatedAt(Instant.now());
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
|
||||||
|
var result = service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
|
assertThat(result.personId()).isEqualTo(alice.getId());
|
||||||
|
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
|
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||||
|
assertThat(result.fromYear()).isEqualTo(1900);
|
||||||
|
assertThat(result.notes()).isEqualTo("first born");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_succeeds_when_viewpoint_is_object() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
// Bob is the storage related_person; deleting from his viewpoint should work.
|
||||||
|
service.deleteRelationship(bob.getId(), relId);
|
||||||
|
verify(relationshipRepository).delete(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
|
||||||
|
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
|
||||||
|
// Two edges exist: alice→bob (both family) and alice→charlie (one non-family).
|
||||||
|
// Only the alice→bob edge must appear in the returned NetworkDTO.
|
||||||
|
UUID aliceBobRelId = UUID.randomUUID();
|
||||||
|
UUID aliceCharlieRelId = UUID.randomUUID();
|
||||||
|
PersonRelationship aliceBob = parentOf(alice, bob, aliceBobRelId);
|
||||||
|
PersonRelationship aliceCharlie = parentOf(alice, charlie, aliceCharlieRelId);
|
||||||
|
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(alice, bob));
|
||||||
|
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of(aliceBob, aliceCharlie));
|
||||||
|
|
||||||
|
NetworkDTO result = service.getFamilyNetwork();
|
||||||
|
|
||||||
|
assertThat(result.nodes()).hasSize(2);
|
||||||
|
assertThat(result.edges()).hasSize(1);
|
||||||
|
assertThat(result.edges().get(0).personId()).isEqualTo(alice.getId());
|
||||||
|
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private static Person person(String name) {
|
||||||
|
return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
|
||||||
|
return PersonRelationship.builder()
|
||||||
|
.id(id)
|
||||||
|
.person(parent)
|
||||||
|
.relatedPerson(child)
|
||||||
|
.relationType(RelationType.PARENT_OF)
|
||||||
|
.createdAt(Instant.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,6 +179,22 @@ class DocumentFtsTest {
|
|||||||
assertThat(ids).isEmpty();
|
assertThat(ids).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word() {
|
||||||
|
// "Wille" stems to "will" via the German Snowball stemmer.
|
||||||
|
// "will" is also a German stop word, so to_tsquery('german','will:*') drops it.
|
||||||
|
// The prefix-transform step must use to_tsquery('simple',...) to avoid this.
|
||||||
|
Document doc = documentRepository.saveAndFlush(document("Foto"));
|
||||||
|
UUID annotationId = annotation(doc.getId());
|
||||||
|
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Der Wille des Volkes", 0));
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
|
||||||
|
|
||||||
|
assertThat(ids).contains(doc.getId());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
||||||
documentRepository.saveAndFlush(document("Brief"));
|
documentRepository.saveAndFlush(document("Brief"));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
import org.raddatz.familienarchiv.model.*;
|
import org.raddatz.familienarchiv.model.*;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
@@ -24,6 +25,7 @@ class TrainingBlockQueryTest {
|
|||||||
@Autowired TranscriptionBlockRepository blockRepository;
|
@Autowired TranscriptionBlockRepository blockRepository;
|
||||||
@Autowired DocumentRepository documentRepository;
|
@Autowired DocumentRepository documentRepository;
|
||||||
@Autowired AnnotationRepository annotationRepository;
|
@Autowired AnnotationRepository annotationRepository;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
|
|
||||||
private UUID kurrentDocId;
|
private UUID kurrentDocId;
|
||||||
private UUID typewriterDocId;
|
private UUID typewriterDocId;
|
||||||
@@ -36,7 +38,7 @@ class TrainingBlockQueryTest {
|
|||||||
.title("Kurrent Brief")
|
.title("Kurrent Brief")
|
||||||
.originalFilename("kurrent.pdf")
|
.originalFilename("kurrent.pdf")
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
.trainingLabels(new java.util.HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
.trainingLabels(kurrentLabels())
|
||||||
.build());
|
.build());
|
||||||
kurrentDocId = kurrentDoc.getId();
|
kurrentDocId = kurrentDoc.getId();
|
||||||
|
|
||||||
@@ -111,8 +113,105 @@ class TrainingBlockQueryTest {
|
|||||||
assertThat(result).hasSize(2);
|
assertThat(result).hasSize(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── sender-based queries ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findManualKurrentBlocksByPerson_includesBlockFromKurrentLabelledDocument() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.trainingLabels(kurrentLabels())
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findManualKurrentBlocksByPerson_excludesDocumentWithoutKurrentLabel() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findManualKurrentBlocksByPerson_excludesOcrBlocks() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.trainingLabels(kurrentLabels())
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.OCR, false));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findManualKurrentBlocksByPerson_excludesOtherSender() {
|
||||||
|
Person karl = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(karl)
|
||||||
|
.trainingLabels(kurrentLabels())
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(anna.getId());
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countManualKurrentBlocksByPerson_matchesFindResult() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.trainingLabels(kurrentLabels())
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, true));
|
||||||
|
|
||||||
|
long count = blockRepository.countManualKurrentBlocksByPerson(sender.getId());
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Set<TrainingLabel> kurrentLabels() {
|
||||||
|
return new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION));
|
||||||
|
}
|
||||||
|
|
||||||
private DocumentAnnotation annotation(UUID docId) {
|
private DocumentAnnotation annotation(UUID docId) {
|
||||||
return DocumentAnnotation.builder()
|
return DocumentAnnotation.builder()
|
||||||
.documentId(docId)
|
.documentId(docId)
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonMention;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class TranscriptionBlockMentionsRepositoryTest {
|
||||||
|
|
||||||
|
@Autowired TranscriptionBlockRepository blockRepository;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
@Autowired AnnotationRepository annotationRepository;
|
||||||
|
@Autowired EntityManager em;
|
||||||
|
|
||||||
|
private UUID documentId;
|
||||||
|
private UUID annotationId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Letter")
|
||||||
|
.originalFilename("letter.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build());
|
||||||
|
documentId = doc.getId();
|
||||||
|
|
||||||
|
DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder()
|
||||||
|
.documentId(documentId)
|
||||||
|
.pageNumber(1)
|
||||||
|
.x(0.1).y(0.2).width(0.3).height(0.4)
|
||||||
|
.color("#00C7B1")
|
||||||
|
.build());
|
||||||
|
annotationId = annotation.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mentionedPersons_roundTripsTwoEntries() {
|
||||||
|
UUID auguste = UUID.randomUUID();
|
||||||
|
UUID hermann = UUID.randomUUID();
|
||||||
|
|
||||||
|
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
|
.annotationId(annotationId)
|
||||||
|
.documentId(documentId)
|
||||||
|
.text("Liebe Tante @Auguste Raddatz, Onkel @Hermann Müller schreibt …")
|
||||||
|
.sortOrder(0)
|
||||||
|
.mentionedPersons(List.of(
|
||||||
|
new PersonMention(auguste, "Auguste Raddatz"),
|
||||||
|
new PersonMention(hermann, "Hermann Müller")
|
||||||
|
))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(reloaded.getMentionedPersons())
|
||||||
|
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
|
||||||
|
.containsExactlyInAnyOrder(
|
||||||
|
org.assertj.core.groups.Tuple.tuple(auguste, "Auguste Raddatz"),
|
||||||
|
org.assertj.core.groups.Tuple.tuple(hermann, "Hermann Müller"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mentionedPersons_defaultsToEmptyList_whenNotSet() {
|
||||||
|
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
|
.annotationId(annotationId)
|
||||||
|
.documentId(documentId)
|
||||||
|
.text("Plain text without mentions")
|
||||||
|
.sortOrder(0)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getMentionedPersons()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
||||||
|
UUID augusteId = UUID.randomUUID();
|
||||||
|
UUID hermannId = UUID.randomUUID();
|
||||||
|
|
||||||
|
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
|
.annotationId(annotationId).documentId(documentId)
|
||||||
|
.text("Brief von @Auguste Raddatz an @Hermann Müller.")
|
||||||
|
.sortOrder(0)
|
||||||
|
.mentionedPersons(List.of(
|
||||||
|
new PersonMention(augusteId, "Auguste Raddatz"),
|
||||||
|
new PersonMention(hermannId, "Hermann Müller")))
|
||||||
|
.build());
|
||||||
|
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
|
.annotationId(annotationId).documentId(documentId)
|
||||||
|
.text("Unrelated block without Auguste.")
|
||||||
|
.sortOrder(1)
|
||||||
|
.mentionedPersons(List.of(new PersonMention(hermannId, "Hermann Müller")))
|
||||||
|
.build());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result =
|
||||||
|
blockRepository.findByPersonIdWithMentionsFetched(augusteId);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getMentionedPersons())
|
||||||
|
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
|
||||||
|
.containsExactlyInAnyOrder(
|
||||||
|
org.assertj.core.groups.Tuple.tuple(augusteId, "Auguste Raddatz"),
|
||||||
|
org.assertj.core.groups.Tuple.tuple(hermannId, "Hermann Müller"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
|||||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||||
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
@@ -120,6 +121,23 @@ class DocumentServiceTest {
|
|||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsArchiveBoxAndFolder() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenReturn(doc);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setArchiveBox("K-03");
|
||||||
|
dto.setArchiveFolder("Mappe B");
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null, null);
|
||||||
|
|
||||||
|
assertThat(doc.getArchiveBox()).isEqualTo("K-03");
|
||||||
|
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1813,4 +1831,437 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── storeDocumentWithBatchMetadata ──────────────────────────────────────
|
||||||
|
|
||||||
|
private MockMultipartFile pdfFile(String name) {
|
||||||
|
return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubStoreDocument(String filename) throws Exception {
|
||||||
|
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
|
||||||
|
stubStoreDocument("scan01.pdf");
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));
|
||||||
|
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);
|
||||||
|
|
||||||
|
assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
stubStoreDocument("scan02.pdf");
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Person sender = Person.builder().id(senderId).firstName("Anna").build();
|
||||||
|
when(personService.getById(senderId)).thenReturn(sender);
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
meta.setSenderId(senderId);
|
||||||
|
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);
|
||||||
|
|
||||||
|
assertThat(result.document().getSender()).isEqualTo(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
Document d = inv.getArgument(0);
|
||||||
|
if (d.getId() == null) d.setId(docId);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
when(documentRepository.findById(docId)).thenAnswer(inv -> {
|
||||||
|
Document d = new Document();
|
||||||
|
d.setId(docId);
|
||||||
|
return Optional.of(d);
|
||||||
|
});
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||||
|
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
meta.setTagNames(List.of("Familie"));
|
||||||
|
|
||||||
|
documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);
|
||||||
|
|
||||||
|
verify(tagService).findOrCreate("Familie");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
|
||||||
|
stubStoreDocument("scan04.pdf");
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
meta.setTitles(List.of("Only One Title"));
|
||||||
|
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);
|
||||||
|
|
||||||
|
assertThat(result.document().getTitle()).isEqualTo("scan04");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── validateBatch ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
|
||||||
|
assertThatThrownBy(() -> documentService.validateBatch(51, null))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("50");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
|
||||||
|
documentService.validateBatch(50, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
|
||||||
|
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||||
|
metadata.setTitles(java.util.List.of("A", "B", "C"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("titles");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── applyBulkEditToDocument ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static org.raddatz.familienarchiv.dto.DocumentBulkEditDTO bulkDto() {
|
||||||
|
return new org.raddatz.familienarchiv.dto.DocumentBulkEditDTO();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto(), null))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining(id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||||
|
Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T")
|
||||||
|
.tags(new HashSet<>(Set.of(existing)))
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(tagService.findOrCreate("Kurrent")).thenReturn(added);
|
||||||
|
|
||||||
|
var dto = bulkDto();
|
||||||
|
dto.setTagNames(List.of("Kurrent"));
|
||||||
|
documentService.applyBulkEditToDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T")
|
||||||
|
.tags(new HashSet<>(Set.of(existing)))
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
documentService.applyBulkEditToDocument(id, bulkDto(), null);
|
||||||
|
|
||||||
|
assertThat(doc.getTags()).containsExactly(existing);
|
||||||
|
verify(tagService, never()).findOrCreate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T")
|
||||||
|
.tags(new HashSet<>(Set.of(existing)))
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var dto = bulkDto();
|
||||||
|
dto.setTagNames(List.of());
|
||||||
|
documentService.applyBulkEditToDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(doc.getTags()).containsExactly(existing);
|
||||||
|
verify(tagService, never()).findOrCreate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||||
|
Person newSender = Person.builder().id(senderId).firstName("New").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T")
|
||||||
|
.sender(oldSender)
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.getById(senderId)).thenReturn(newSender);
|
||||||
|
|
||||||
|
var dto = bulkDto();
|
||||||
|
dto.setSenderId(senderId);
|
||||||
|
documentService.applyBulkEditToDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(doc.getSender()).isEqualTo(newSender);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T")
|
||||||
|
.sender(existing)
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
documentService.applyBulkEditToDocument(id, bulkDto(), null);
|
||||||
|
|
||||||
|
assertThat(doc.getSender()).isEqualTo(existing);
|
||||||
|
verify(personService, never()).getById(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UUID newReceiverId = UUID.randomUUID();
|
||||||
|
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||||
|
Person added = Person.builder().id(newReceiverId).firstName("New").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T")
|
||||||
|
.receivers(new HashSet<>(Set.of(existing)))
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added));
|
||||||
|
|
||||||
|
var dto = bulkDto();
|
||||||
|
dto.setReceiverIds(List.of(newReceiverId));
|
||||||
|
documentService.applyBulkEditToDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T")
|
||||||
|
.receivers(new HashSet<>(Set.of(existing)))
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var dto = bulkDto();
|
||||||
|
dto.setReceiverIds(List.of());
|
||||||
|
documentService.applyBulkEditToDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(doc.getReceivers()).containsExactly(existing);
|
||||||
|
verify(personService, never()).getAllById(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenReturn(doc);
|
||||||
|
|
||||||
|
documentService.applyBulkEditToDocument(id, bulkDto(), actorId);
|
||||||
|
|
||||||
|
verify(documentVersionService).recordVersion(doc);
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
eq(AuditKind.METADATA_UPDATED),
|
||||||
|
eq(actorId),
|
||||||
|
eq(id),
|
||||||
|
eq(java.util.Map.of("source", "BULK_EDIT")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("T")
|
||||||
|
.archiveBox("OldBox")
|
||||||
|
.archiveFolder("OldFolder")
|
||||||
|
.documentLocation("OldLocation")
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var dto = bulkDto();
|
||||||
|
dto.setArchiveBox("NewBox");
|
||||||
|
dto.setArchiveFolder("NewFolder");
|
||||||
|
dto.setDocumentLocation("NewLocation");
|
||||||
|
documentService.applyBulkEditToDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
|
||||||
|
assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
|
||||||
|
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable() {
|
||||||
|
// Sara C1 — unresolvable sender flows up as a per-document error chip
|
||||||
|
// rather than aborting the controller's batch loop.
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UUID unknownSender = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(personService.getById(unknownSender))
|
||||||
|
.thenThrow(DomainException.notFound(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND,
|
||||||
|
"Person not found: " + unknownSender));
|
||||||
|
|
||||||
|
var dto = bulkDto();
|
||||||
|
dto.setSenderId(unknownSender);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, dto, null))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining(unknownSender.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findIdsForFilter ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findIdsForFilter_returnsAllMatchingIds_uncapped() {
|
||||||
|
Document d1 = Document.builder().id(UUID.randomUUID()).title("A").build();
|
||||||
|
Document d2 = Document.builder().id(UUID.randomUUID()).title("B").build();
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
|
.thenReturn(List.of(d1, d2));
|
||||||
|
|
||||||
|
List<UUID> result = documentService.findIdsForFilter(
|
||||||
|
null, null, null, null, null, null, null, null, null);
|
||||||
|
|
||||||
|
assertThat(result).containsExactly(d1.getId(), d2.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec() {
|
||||||
|
// Sara C3 — tagOp=OR flips useOrLogic at the spec layer; without a
|
||||||
|
// test pinning this, a refactor that wired OR to AND (or vice versa)
|
||||||
|
// would slip through.
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
documentService.findIdsForFilter(
|
||||||
|
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR);
|
||||||
|
|
||||||
|
// Spec built without throwing → OR branch was exercised. Coverage gain
|
||||||
|
// is in not-throwing on the OR-specific code path; the actual SQL is
|
||||||
|
// covered by JPA itself.
|
||||||
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
|
||||||
|
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<UUID> result = documentService.findIdsForFilter(
|
||||||
|
"xyz", null, null, null, null, null, null, null, null);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── batchMetadata ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_returnsEmpty_whenIdsIsNull() {
|
||||||
|
assertThat(documentService.batchMetadata(null)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_returnsEmpty_whenIdsIsEmpty() {
|
||||||
|
assertThat(documentService.batchMetadata(List.of())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_returnsSummariesWithPdfUrl_forExistingIds() {
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
Document d1 = Document.builder().id(id1).title("Brief 1").build();
|
||||||
|
Document d2 = Document.builder().id(id2).title("Brief 2").build();
|
||||||
|
when(documentRepository.findAllById(List.of(id1, id2))).thenReturn(List.of(d1, d2));
|
||||||
|
|
||||||
|
var result = documentService.batchMetadata(List.of(id1, id2));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertThat(result.get(0).id()).isEqualTo(id1);
|
||||||
|
assertThat(result.get(0).title()).isEqualTo("Brief 1");
|
||||||
|
assertThat(result.get(0).pdfUrl()).isEqualTo("/api/documents/" + id1 + "/file");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_silentlyDropsUnknownIds() {
|
||||||
|
UUID known = UUID.randomUUID();
|
||||||
|
UUID missing = UUID.randomUUID();
|
||||||
|
Document d = Document.builder().id(known).title("Found").build();
|
||||||
|
when(documentRepository.findAllById(List.of(known, missing))).thenReturn(List.of(d));
|
||||||
|
|
||||||
|
var result = documentService.batchMetadata(List.of(known, missing));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).id()).isEqualTo(known);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchMetadata_fallsBackToOriginalFilename_whenTitleIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document d = Document.builder().id(id).originalFilename("scan001.pdf").build();
|
||||||
|
when(documentRepository.findAllById(List.of(id))).thenReturn(List.of(d));
|
||||||
|
|
||||||
|
var result = documentService.batchMetadata(List.of(id));
|
||||||
|
|
||||||
|
assertThat(result.get(0).title()).isEqualTo("scan001.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("T")
|
||||||
|
.archiveBox("KeepBox")
|
||||||
|
.archiveFolder("KeepFolder")
|
||||||
|
.documentLocation("KeepLocation")
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
var dto = bulkDto();
|
||||||
|
dto.setArchiveBox(" ");
|
||||||
|
dto.setArchiveFolder("");
|
||||||
|
// documentLocation left null
|
||||||
|
documentService.applyBulkEditToDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
|
||||||
|
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
|
||||||
|
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class PersonServiceTest {
|
class PersonServiceTest {
|
||||||
@@ -114,6 +117,43 @@ class PersonServiceTest {
|
|||||||
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── personType + title in createPerson(PersonUpdateDTO) ─────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_persistsPersonType() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION);
|
||||||
|
|
||||||
|
Person result = personService.createPerson(dto);
|
||||||
|
|
||||||
|
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_throwsInvalidPersonType_whenSkip() {
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> personService.createPerson(dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_persistsTitle() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON);
|
||||||
|
|
||||||
|
Person result = personService.createPerson(dto);
|
||||||
|
|
||||||
|
assertThat(result.getTitle()).isEqualTo("Prof.");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -145,6 +185,36 @@ class PersonServiceTest {
|
|||||||
.isEqualTo(400);
|
.isEqualTo(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── updatePerson (personType) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_throwsInvalidPersonType_whenSkip() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_persistsPersonType() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION);
|
||||||
|
|
||||||
|
Person result = personService.updatePerson(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import org.raddatz.familienarchiv.model.BlockSource;
|
|||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonMention;
|
||||||
import org.raddatz.familienarchiv.model.ScriptType;
|
import org.raddatz.familienarchiv.model.ScriptType;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||||
@@ -98,7 +99,9 @@ class TranscriptionServiceTest {
|
|||||||
return b;
|
return b;
|
||||||
});
|
});
|
||||||
|
|
||||||
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
|
CreateTranscriptionBlockDTO dto = CreateTranscriptionBlockDTO.builder()
|
||||||
|
.pageNumber(1).x(0.1).y(0.2).width(0.3).height(0.4)
|
||||||
|
.text("hello").build();
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
||||||
|
|
||||||
@@ -168,7 +171,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
|
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("new text").build();
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
||||||
|
|
||||||
@@ -189,7 +192,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
|
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("text").label("Anrede").build();
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||||
|
|
||||||
@@ -208,11 +211,65 @@ class TranscriptionServiceTest {
|
|||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(
|
TranscriptionBlock result = transcriptionService.updateBlock(
|
||||||
docId, blockId, new UpdateTranscriptionBlockDTO("new", null), UUID.randomUUID());
|
docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new").build(), UUID.randomUUID());
|
||||||
|
|
||||||
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
|
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBlock_replacesMentionedPersonsFromDto() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).text("old").build();
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||||
|
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
|
PersonMention mention = new PersonMention(personId, "Auguste");
|
||||||
|
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder()
|
||||||
|
.text("@Auguste text")
|
||||||
|
.mentionedPersons(List.of(mention))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(result.getMentionedPersons())
|
||||||
|
.containsExactly(mention);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBlock_clearsPriorMentions_beforeApplyingDto() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
|
||||||
|
PersonMention prior = new PersonMention(UUID.randomUUID(), "Heinrich");
|
||||||
|
PersonMention incoming = new PersonMention(UUID.randomUUID(), "Auguste");
|
||||||
|
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).text("old").build();
|
||||||
|
block.getMentionedPersons().add(prior);
|
||||||
|
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||||
|
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
|
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder()
|
||||||
|
.text("@Auguste text")
|
||||||
|
.mentionedPersons(List.of(incoming))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(result.getMentionedPersons())
|
||||||
|
.containsExactly(incoming)
|
||||||
|
.doesNotContain(prior);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
|
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
@@ -226,7 +283,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
|
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
||||||
|
|
||||||
verify(senderModelService).checkAndTriggerTraining(senderId);
|
verify(senderModelService).checkAndTriggerTraining(senderId);
|
||||||
}
|
}
|
||||||
@@ -242,7 +299,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
|
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
||||||
|
|
||||||
verify(senderModelService, never()).checkAndTriggerTraining(any());
|
verify(senderModelService, never()).checkAndTriggerTraining(any());
|
||||||
}
|
}
|
||||||
@@ -477,7 +534,7 @@ class TranscriptionServiceTest {
|
|||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
|
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null), userId);
|
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId);
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||||
@@ -502,8 +559,90 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("same text", null), userId);
|
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("same text").build(), userId);
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||||
|
.thenReturn(List.of(block1, block2));
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||||
|
verify(blockRepository).saveAll(List.of(block1, block2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||||
|
.thenReturn(List.of(block));
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||||
|
verify(blockRepository).saveAll(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||||
|
.thenReturn(List.of(block1, block2));
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||||
|
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||||
|
.thenReturn(List.of(alreadyReviewed, unreviewed));
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
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;
|
||||||
@@ -34,6 +37,7 @@ class UserServiceTest {
|
|||||||
@Mock AppUserRepository userRepository;
|
@Mock AppUserRepository userRepository;
|
||||||
@Mock UserGroupRepository groupRepository;
|
@Mock UserGroupRepository groupRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
|
@Mock AuditService auditService;
|
||||||
@InjectMocks UserService userService;
|
@InjectMocks UserService userService;
|
||||||
|
|
||||||
// ─── findByEmail ──────────────────────────────────────────────────────────
|
// ─── findByEmail ──────────────────────────────────────────────────────────
|
||||||
@@ -61,7 +65,7 @@ class UserServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.deleteUser(id))
|
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
|
||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ class UserServiceTest {
|
|||||||
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
userService.deleteUser(id);
|
userService.deleteUser(UUID.randomUUID(), id);
|
||||||
|
|
||||||
verify(userRepository).delete(user);
|
verify(userRepository).delete(user);
|
||||||
}
|
}
|
||||||
@@ -90,7 +94,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(req);
|
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(userRepository).save(any());
|
verify(userRepository).save(any());
|
||||||
@@ -108,7 +112,7 @@ class UserServiceTest {
|
|||||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||||
when(userRepository.save(any())).thenReturn(existing);
|
when(userRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
userService.createUserOrUpdate(req);
|
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
verify(userRepository, times(1)).save(existing);
|
verify(userRepository, times(1)).save(existing);
|
||||||
}
|
}
|
||||||
@@ -229,7 +233,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getFirstName()).isEqualTo("Ada");
|
assertThat(result.getFirstName()).isEqualTo("Ada");
|
||||||
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
||||||
@@ -246,7 +250,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada");
|
dto.setFirstName("Ada");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(adminGroup);
|
assertThat(result.getGroups()).containsExactly(adminGroup);
|
||||||
}
|
}
|
||||||
@@ -264,7 +268,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of(newGroup.getId()));
|
dto.setGroupIds(List.of(newGroup.getId()));
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(newGroup);
|
assertThat(result.getGroups()).containsExactly(newGroup);
|
||||||
}
|
}
|
||||||
@@ -281,7 +285,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of());
|
dto.setGroupIds(List.of());
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).isEmpty();
|
assertThat(result.getGroups()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -313,7 +317,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(req);
|
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(groupRepository).findAllById(List.of(group.getId()));
|
verify(groupRepository).findAllById(List.of(group.getId()));
|
||||||
@@ -378,7 +382,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword("newSecret");
|
dto.setNewPassword("newSecret");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("newHashed");
|
assertThat(result.getPassword()).isEqualTo("newHashed");
|
||||||
}
|
}
|
||||||
@@ -393,7 +397,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword(" ");
|
dto.setNewPassword(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("original");
|
assertThat(result.getPassword()).isEqualTo("original");
|
||||||
verify(passwordEncoder, never()).encode(any());
|
verify(passwordEncoder, never()).encode(any());
|
||||||
@@ -408,7 +412,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(" ");
|
dto.setEmail(" ");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("blank");
|
.hasMessageContaining("blank");
|
||||||
}
|
}
|
||||||
@@ -425,7 +429,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("taken@example.com");
|
dto.setEmail("taken@example.com");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("E-Mail");
|
.hasMessageContaining("E-Mail");
|
||||||
}
|
}
|
||||||
@@ -497,7 +501,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(req);
|
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -561,7 +565,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(null);
|
dto.setContact(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -576,7 +580,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" ");
|
dto.setContact(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -591,7 +595,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" phone: 555 ");
|
dto.setContact(" phone: 555 ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isEqualTo("phone: 555");
|
assertThat(result.getContact()).isEqualTo("phone: 555");
|
||||||
}
|
}
|
||||||
@@ -606,7 +610,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(null);
|
dto.setEmail(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
||||||
}
|
}
|
||||||
@@ -622,7 +626,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("me@example.com");
|
dto.setEmail("me@example.com");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +644,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(req);
|
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -699,6 +703,160 @@ class UserServiceTest {
|
|||||||
assertThat(result).containsExactly(g);
|
assertThat(result).containsExactly(g);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── audit: GROUP_MEMBERSHIP_CHANGED ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build();
|
||||||
|
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build();
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||||
|
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setGroupIds(List.of(newGroup.getId()));
|
||||||
|
|
||||||
|
userService.adminUpdateUser(actorId, userId, dto);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED),
|
||||||
|
org.mockito.ArgumentMatchers.eq(actorId),
|
||||||
|
org.mockito.ArgumentMatchers.isNull(),
|
||||||
|
payloadCaptor.capture());
|
||||||
|
java.util.Map<String, Object> payload = payloadCaptor.getValue();
|
||||||
|
assertThat(payload).containsEntry("email", "u@example.com");
|
||||||
|
assertThat((java.util.List<String>) payload.get("addedGroups")).containsExactly("Editors");
|
||||||
|
assertThat((java.util.List<String>) payload.get("removedGroups")).containsExactly("Viewers");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||||
|
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setGroupIds(List.of(group.getId()));
|
||||||
|
|
||||||
|
userService.adminUpdateUser(actorId, userId, dto);
|
||||||
|
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
// groupIds not set → null
|
||||||
|
|
||||||
|
userService.adminUpdateUser(actorId, userId, dto);
|
||||||
|
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── audit: USER_DELETED ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUser_logsUserDeleted_withEmailInPayload() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("gone@example.com").build();
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
userService.deleteUser(actorId, userId);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED),
|
||||||
|
org.mockito.ArgumentMatchers.eq(actorId),
|
||||||
|
org.mockito.ArgumentMatchers.isNull(),
|
||||||
|
payloadCaptor.capture());
|
||||||
|
assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com");
|
||||||
|
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── audit: USER_CREATED ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserOrUpdate_logsUserCreated_whenUserIsNew() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setEmail("new@example.com");
|
||||||
|
req.setInitialPassword("secret");
|
||||||
|
req.setGroupIds(List.of());
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
|
||||||
|
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||||
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||||
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
userService.createUserOrUpdate(actorId, req);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED),
|
||||||
|
org.mockito.ArgumentMatchers.eq(actorId),
|
||||||
|
org.mockito.ArgumentMatchers.isNull(),
|
||||||
|
payloadCaptor.capture());
|
||||||
|
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
||||||
|
assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setEmail("existing@example.com");
|
||||||
|
req.setInitialPassword("pass");
|
||||||
|
req.setGroupIds(List.of());
|
||||||
|
|
||||||
|
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
|
||||||
|
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
|
||||||
|
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||||
|
when(userRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
userService.createUserOrUpdate(actorId, req);
|
||||||
|
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createUserForBootstrap ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserForBootstrap_createsUserWithoutAuditEvent() {
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setEmail("bootstrap@example.com");
|
||||||
|
req.setInitialPassword("secret");
|
||||||
|
req.setGroupIds(List.of());
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty());
|
||||||
|
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||||
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build();
|
||||||
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
AppUser result = userService.createUserForBootstrap(req);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(saved);
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── createGroup ──────────────────────────────────────────────────────────
|
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
55
docs/adr/006-synchronous-domain-events-in-transaction.md
Normal file
55
docs/adr/006-synchronous-domain-events-in-transaction.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# ADR-006: Synchronous domain events inside the publisher's transaction
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Issue #362 introduced the first cross-domain side-effect in this codebase: when a Person's display name changes, every transcription block that mentions the person must be rewritten — both `block.text` (the literal `@OldName` substring) and the `mentionedPersons` sidecar (the `displayName` field on the matching `PersonMention`). The rewrite is bidirectionally referential — Person depends on Transcription to make the rename atomic, and Transcription depends on Person to know what the new display name is.
|
||||||
|
|
||||||
|
A direct method call from `PersonService` into `TranscriptionBlockService` would invert the existing dependency arrow (Document → Person, not Person → Transcription) and introduce a runtime-circular reference at the package level. Avoiding the cycle while keeping the rename atomic is the constraint this ADR addresses.
|
||||||
|
|
||||||
|
Two prior pieces of infrastructure constrain the solution:
|
||||||
|
|
||||||
|
- `transcription_blocks.version` (JPA `@Version`) — concurrent autosave on a referenced block must roll back the rename instead of silently overwriting the autosave.
|
||||||
|
- `OcrTrainingService.recoverOrphanedRuns` is the only existing `@EventListener` and it consumes Spring's built-in `ApplicationReadyEvent` — no precedent for a custom domain event in this codebase before now.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`PersonService.updatePerson` publishes `PersonDisplayNameChangedEvent(personId, oldDisplayName, newDisplayName)` via `ApplicationEventPublisher` whenever `Person.getDisplayName()` flips between the pre-save snapshot and the post-save value. `PersonMentionPropagationListener` (in the transcription package's `service/` layer) handles the event with `@EventListener @Transactional`, finds blocks via `findByMentionedPersons_PersonId`, rewrites text + sidecar, and calls `saveAllAndFlush`.
|
||||||
|
|
||||||
|
**Synchronous on purpose.** Spring's default event dispatcher invokes listeners on the publishing thread, inside the publisher's transaction. The propagation runs as part of the same `@Transactional` boundary as the rename — `OptimisticLockingFailureException` from a referenced block bubbles back up, the surrounding transaction rolls back, and `PersonService.updatePerson` translates it to `DomainException(PERSON_RENAME_CONFLICT, 409)`.
|
||||||
|
|
||||||
|
**Pattern for future cross-domain decoupling:**
|
||||||
|
1. Event record in `model/` of the publishing domain (e.g. `PersonDisplayNameChangedEvent`).
|
||||||
|
2. Listener in `service/` of the consuming domain (e.g. `PersonMentionPropagationListener`).
|
||||||
|
3. `@EventListener @Transactional` on the listener method — no `@TransactionalEventListener` unless the work genuinely doesn't need to commit with the publisher.
|
||||||
|
4. `saveAllAndFlush` (not `saveAll`) on any write where exceptions must surface inside the listener call so the publisher can catch and translate them — `saveAll` defers exceptions to commit time, after the publisher's `try` block has exited.
|
||||||
|
5. Audit log line at `INFO` level on the listener method — historical-text mutation needs an audit trail.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
| Alternative | Why rejected |
|
||||||
|
|---|---|
|
||||||
|
| `PersonService` calls `TranscriptionBlockService.propagateDisplayNameChange(...)` directly | Inverts the dependency arrow. Person becomes runtime-coupled to Transcription; future domains that also care about renames (Comments, Notifications) compound the coupling. Events keep Person agnostic of who consumes them. |
|
||||||
|
| `@TransactionalEventListener(AFTER_COMMIT) + @Async` | The propagation would run after the rename commits, on a separate transaction. A failed propagation could leave block text out of sync with the renamed person until manual repair. Atomic transactional coupling is the safer default for historical-text mutation; switch to async only when the block count makes sync latency unacceptable (rough threshold: tens of thousands of blocks per renamed person). |
|
||||||
|
| Database trigger on `persons.last_name` | PL/pgSQL trigger would have to reach into `transcription_block_mentioned_persons` and `transcription_blocks.text`, smearing domain logic across SQL and Java. JPA's `@Version` would also be invisible to the trigger, so concurrent block autosaves would race silently. |
|
||||||
|
| Hibernate entity listener (`@PostUpdate` on Person) | Couples to Hibernate internals; harder to test in isolation; mixes lifecycle hooks with cross-domain side effects. Spring's `ApplicationEventPublisher` keeps the integration declarative and unit-testable. |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Easier:**
|
||||||
|
- Person domain stays free of any compile-time dependency on Transcription. Future consumers (Comments, Notifications) subscribe to the same event without `PersonService` knowing they exist.
|
||||||
|
- Rename + propagation share one transaction → no half-applied state visible to readers, no orphaned rewrites if the rename fails after propagation, no "eventually-consistent" window for an archive that prizes historical fidelity.
|
||||||
|
- Concurrent autosaves on referenced blocks raise a structured 409 the frontend can render meaningfully (`error_person_rename_conflict`) instead of a generic 500.
|
||||||
|
- The pattern itself (record event in `model/`, listener in consumer's `service/`, sync `@EventListener @Transactional`, `saveAllAndFlush`) is reusable for the next cross-domain side effect.
|
||||||
|
|
||||||
|
**Harder:**
|
||||||
|
- Listener latency adds to the rename request's response time. The 200-block latency floor (< 2 s) is a merge-blocking regression test; if archive growth pushes it up, the migration path is one-annotation: switch to `@TransactionalEventListener(AFTER_COMMIT) + @Async` and add a manual-repair tool for propagation failures.
|
||||||
|
- Tests for the listener path require routing the publisher mock through a real listener (see `PersonServiceTest#updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock`). Slightly more setup than a pure-Mockito test, but exercises the production call chain.
|
||||||
|
- `saveAllAndFlush` is mandatory in any synchronous listener that must surface JPA exceptions to the publisher's `try`-block. `saveAll` alone defers the flush to transaction commit, which happens after the publisher returns.
|
||||||
|
|
||||||
|
## Future Direction
|
||||||
|
|
||||||
|
If a single rename starts touching tens of thousands of blocks, switch the listener to `@TransactionalEventListener(phase = AFTER_COMMIT)` paired with `@Async` and add (a) an idempotency key to the event so a retry doesn't double-rewrite, (b) an admin tool that scans for sidecar entries whose `displayName` doesn't match the current `Person.getDisplayName()` and repairs them. At that point the orphan-guard path (existsById check before the rewrite) re-enters the listener as a deliberate piece of the async machinery rather than dead code.
|
||||||
987
docs/specs/stammbaum-doc-badge-spec.html
Normal file
987
docs/specs/stammbaum-doc-badge-spec.html
Normal file
@@ -0,0 +1,987 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Stammbaum — Document Badge · Inline Pill Variant · Familienarchiv</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||||
|
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
|
||||||
|
|
||||||
|
/* ── Masthead ── */
|
||||||
|
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:60px}
|
||||||
|
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
|
||||||
|
.mh p{font-size:13px;color:#555;max-width:740px;line-height:1.75;margin-top:8px}
|
||||||
|
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
|
||||||
|
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
|
||||||
|
.tag{background:#012851;color:#A1DCD8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||||
|
.tag.amber{background:#7c4a00;color:#fde68a}
|
||||||
|
|
||||||
|
/* ── Section headers ── */
|
||||||
|
.sh{margin:0 0 28px}
|
||||||
|
.sh h2{font-size:16px;font-weight:900;color:#012851;letter-spacing:-.2px}
|
||||||
|
.sh p{font-size:12.5px;color:#666;max-width:720px;line-height:1.7;margin-top:5px}
|
||||||
|
.section{margin-bottom:80px;padding-bottom:80px;border-bottom:2px dashed #C8C4BE}
|
||||||
|
.section:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||||
|
|
||||||
|
/* ── Token tables ── */
|
||||||
|
.token-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
|
||||||
|
.token-table{border-radius:6px;overflow:hidden}
|
||||||
|
.token-table.light{background:#fff;border:1px solid #E0DDD6}
|
||||||
|
.token-table.dark{background:#0F1923;border:1px solid #1E2D3D}
|
||||||
|
.token-head{padding:8px 14px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;border-bottom:1px solid #E0DDD6}
|
||||||
|
.token-table.light .token-head{background:#F4F2EC;color:#888;border-bottom-color:#E0DDD6}
|
||||||
|
.token-table.dark .token-head{background:#0A1218;color:#4E6070;border-bottom-color:#1E2D3D}
|
||||||
|
.token-table table{width:100%;border-collapse:collapse;font-size:11px}
|
||||||
|
.token-table.light td{padding:6px 14px;border-bottom:1px solid #F0EEE8;vertical-align:middle}
|
||||||
|
.token-table.dark td{padding:6px 14px;border-bottom:1px solid #1A2830;vertical-align:middle;color:#8AAABB}
|
||||||
|
.token-table tr:last-child td{border-bottom:none}
|
||||||
|
.token-table.light td:first-child{font-size:9px;font-weight:700;color:#888;width:160px}
|
||||||
|
.token-table.dark td:first-child{font-size:9px;font-weight:700;color:#4E6070;width:160px}
|
||||||
|
.swatch{display:inline-block;width:12px;height:12px;border-radius:2px;vertical-align:middle;margin-right:6px}
|
||||||
|
.swatch.bordered{border:1px solid #DDD}
|
||||||
|
.warn{display:inline-block;background:#FEF3C7;color:#92400E;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
|
||||||
|
.pass{display:inline-block;background:#D1FAE5;color:#065F46;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
|
||||||
|
|
||||||
|
/* ── Browser chrome ── */
|
||||||
|
.chrome{border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
|
||||||
|
.chrome.dark{background:#010e1e;border-color:#0d3358}
|
||||||
|
.chrome-bar{height:20px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 8px;gap:4px;flex-shrink:0}
|
||||||
|
.chrome.dark .chrome-bar{background:#010a18;border-bottom-color:#0d3358}
|
||||||
|
.chrome-dot{width:6px;height:6px;border-radius:50%;background:#BDB8B1}
|
||||||
|
.chrome.dark .chrome-dot{background:#1a2a3a}
|
||||||
|
.chrome-url{flex:1;height:9px;background:#CCC8C2;border-radius:5px;margin-left:6px}
|
||||||
|
.chrome.dark .chrome-url{background:#1a2a3a}
|
||||||
|
|
||||||
|
/* ── App nav ── */
|
||||||
|
.app-nav{height:34px;background:#012851;border-top:4px solid #A1DCD8;display:flex;align-items:center;padding:0 12px;gap:10px;flex-shrink:0}
|
||||||
|
.app-logo{font-family:'Tinos',Georgia,serif;font-size:7px;font-weight:700;color:#fff;letter-spacing:.5px}
|
||||||
|
.app-link{font-size:5.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.4);white-space:nowrap}
|
||||||
|
.app-link.on{color:rgba(255,255,255,.9)}
|
||||||
|
.app-nav-r{margin-left:auto;display:flex;gap:6px;align-items:center}
|
||||||
|
.app-av{width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5)}
|
||||||
|
|
||||||
|
/* ── Sub-header bar ── */
|
||||||
|
.sub-header{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:6px;flex-shrink:0}
|
||||||
|
.chrome.dark .sub-header{background:#011526;border-bottom-color:#0d3358}
|
||||||
|
.back-btn{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
|
||||||
|
.chrome.dark .back-btn{color:#8b97a5}
|
||||||
|
.sh-divider{width:1px;height:18px;background:#E4E2D7;flex-shrink:0;margin:0 4px}
|
||||||
|
.chrome.dark .sh-divider{background:#0d3358}
|
||||||
|
.sh-doc-title{font-family:'Tinos',Georgia,serif;font-size:10px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||||
|
.chrome.dark .sh-doc-title{color:#f0efe9}
|
||||||
|
/* person chips in sub-header */
|
||||||
|
.sh-persons{display:flex;align-items:center;gap:5px;flex-shrink:0}
|
||||||
|
.sh-chip{display:flex;align-items:center;gap:4px}
|
||||||
|
.sh-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
|
||||||
|
.sh-name{font-size:8px;font-weight:600;color:#4b5563;white-space:nowrap}
|
||||||
|
.chrome.dark .sh-name{color:#9ca3af}
|
||||||
|
.sh-arrow{color:#A1DCD8;flex-shrink:0}
|
||||||
|
.chrome.dark .sh-arrow{color:#00c7b1}
|
||||||
|
/* INLINE PILL */
|
||||||
|
.pill{display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;margin-left:5px;vertical-align:middle;line-height:1.5}
|
||||||
|
.chrome.dark .pill{background:rgba(0,199,177,.10);border-color:#00c7b1;color:#f0efe9}
|
||||||
|
/* sub-header actions */
|
||||||
|
.sh-actions{display:flex;align-items:center;gap:5px;flex-shrink:0;margin-left:8px}
|
||||||
|
.sh-btn-ghost{height:22px;padding:0 7px;border:1.5px solid #E4E2D7;border-radius:3px;font-size:6.5px;font-weight:700;color:#4b5563;display:flex;align-items:center;gap:3px;flex-shrink:0}
|
||||||
|
.chrome.dark .sh-btn-ghost{border-color:#0d3358;color:#8b97a5}
|
||||||
|
.sh-btn-primary{height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;gap:3px;flex-shrink:0}
|
||||||
|
.chrome.dark .sh-btn-primary{background:#A1DCD8;color:#012851}
|
||||||
|
.sh-btn-icon{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
|
||||||
|
.chrome.dark .sh-btn-icon{border-color:#0d3358;color:#8b97a5}
|
||||||
|
|
||||||
|
/* ── Metadata drawer ── */
|
||||||
|
.meta-drawer{background:#ffffff;border-bottom:1px solid #E4E2D7;padding:14px 16px;flex-shrink:0}
|
||||||
|
.chrome.dark .meta-drawer{background:#011526;border-bottom-color:#0d3358}
|
||||||
|
.meta-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||||||
|
.meta-col-head{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
|
||||||
|
.chrome.dark .meta-col-head{color:#8b97a5}
|
||||||
|
.meta-field{margin-bottom:8px}
|
||||||
|
.meta-label{font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
|
||||||
|
.chrome.dark .meta-label{color:#8b97a5}
|
||||||
|
.meta-value{font-family:'Tinos',Georgia,serif;font-size:10px;color:#012851}
|
||||||
|
.chrome.dark .meta-value{color:#f0efe9}
|
||||||
|
|
||||||
|
/* ── Person card in metadata ── */
|
||||||
|
.person-card{display:flex;align-items:center;gap:5px;padding:3px 5px;border-radius:3px}
|
||||||
|
.p-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6.5px;font-weight:800;color:#fff;flex-shrink:0}
|
||||||
|
.p-name{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851}
|
||||||
|
.chrome.dark .p-name{color:#f0efe9}
|
||||||
|
|
||||||
|
/* ── PDF placeholder ── */
|
||||||
|
.pdf-area{background:#d4d0c8;flex:1;display:flex;align-items:center;justify-content:center;min-height:80px}
|
||||||
|
.chrome.dark .pdf-area{background:#010e1e}
|
||||||
|
.paper{background:#FFFEF8;width:40%;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:8px 10px;display:flex;flex-direction:column;gap:2px}
|
||||||
|
.chrome.dark .paper{background:#0d1820}
|
||||||
|
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px}
|
||||||
|
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px}
|
||||||
|
.chrome.dark .pl,.chrome.dark .ps{background:#1E2D3D}
|
||||||
|
|
||||||
|
/* ── Side-by-side layout ── */
|
||||||
|
.split-screens{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
|
||||||
|
.screen-lbl{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:8px;display:flex;align-items:center;gap:5px}
|
||||||
|
.lbl-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
|
||||||
|
.cap{font-size:10px;color:#999;font-style:italic;line-height:1.6;margin-top:10px;max-width:460px}
|
||||||
|
|
||||||
|
/* ── Edge-case cards ── */
|
||||||
|
.edge-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:12px}
|
||||||
|
.edge-card{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
|
||||||
|
.edge-head{background:#F4F2EC;padding:8px 12px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;border-bottom:1px solid #E0DDD6}
|
||||||
|
.edge-body{padding:10px 12px}
|
||||||
|
.edge-note{font-size:10.5px;color:#555;line-height:1.65;margin-top:8px}
|
||||||
|
.no-badge{font-family:'Tinos',Georgia,serif;font-size:9px;color:#aaa;font-style:italic;padding:4px 5px}
|
||||||
|
|
||||||
|
/* ── Rules / implementation table ── */
|
||||||
|
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
|
||||||
|
.rules table{width:100%;border-collapse:collapse}
|
||||||
|
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
|
||||||
|
.rules td{font-size:11px;color:#444;padding:8px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.6}
|
||||||
|
.rules tr:last-child td{border-bottom:none}
|
||||||
|
.rules td:first-child{font-size:9px;font-weight:700;color:#012851;white-space:nowrap;width:200px}
|
||||||
|
.rules td code{font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px;color:#555;white-space:nowrap}
|
||||||
|
|
||||||
|
/* ── Pill anatomy callout ── */
|
||||||
|
.pill-anatomy{display:flex;align-items:center;gap:20px;background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:18px 24px;margin-bottom:16px;flex-wrap:wrap}
|
||||||
|
.pill-demo-light{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f9f8f4;border-radius:4px}
|
||||||
|
.pill-demo-dark{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#011526;border-radius:4px}
|
||||||
|
.pill-annotation{font-size:9.5px;color:#888;line-height:1.7}
|
||||||
|
.pill-annotation strong{color:#012851;font-weight:700}
|
||||||
|
|
||||||
|
/* ── Responsive preview containers ── */
|
||||||
|
.responsive-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
|
||||||
|
.responsive-grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:24px;margin-bottom:16px}
|
||||||
|
|
||||||
|
/* ── Tablet sub-header ── */
|
||||||
|
.sub-header-tablet{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0}
|
||||||
|
.sh-title-truncated{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||||
|
.sh-overflow-btn{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;font-size:9px;font-weight:700;flex-shrink:0}
|
||||||
|
.meta-stacked{padding:12px 14px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:9px}
|
||||||
|
.meta-stacked .meta-label{font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
|
||||||
|
.meta-stacked .meta-value{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851;margin-bottom:10px}
|
||||||
|
.meta-stacked .meta-section-head{font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
|
||||||
|
|
||||||
|
/* ── Mobile sub-header ── */
|
||||||
|
.sub-header-mobile{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0}
|
||||||
|
.sh-title-mobile{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||||
|
.meta-mobile{padding:10px 12px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:8.5px}
|
||||||
|
.meta-mobile .m-label{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:2px;margin-top:8px}
|
||||||
|
.meta-mobile .m-label:first-child{margin-top:0}
|
||||||
|
.meta-mobile .m-value{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;margin-bottom:2px}
|
||||||
|
.person-row-mobile{display:flex;align-items:center;gap:4px;flex-wrap:nowrap}
|
||||||
|
.person-row-mobile .p-av-sm{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
|
||||||
|
.person-row-mobile .p-nm{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;white-space:nowrap}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- ══ MASTHEAD ══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="mh">
|
||||||
|
<h1>Stammbaum — Document Badge · Inline Pill Variant</h1>
|
||||||
|
<p>
|
||||||
|
Design spec for the inline relationship pill on the Document Detail page. Relationship labels appear
|
||||||
|
as <strong>inline pills directly next to each person's name</strong> — both in the 48 px sub-header bar
|
||||||
|
and in the Personen column of the 3-column metadata drawer. Example: Karl Raddatz
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">ELTERNTEIL</span>
|
||||||
|
→ Hans Raddatz
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">KIND</span>.
|
||||||
|
This is View 2 of 3 in the Stammbaum document-badge feature set.
|
||||||
|
</p>
|
||||||
|
<div class="byline">Familienarchiv · 2026-04-27 · Leonie Voss, UX Lead</div>
|
||||||
|
<div class="tag-row">
|
||||||
|
<span class="tag">Stammbaum Feature</span>
|
||||||
|
<span class="tag">View 2 of 3 — Document Badge</span>
|
||||||
|
<span class="tag">Inline Pill Variant</span>
|
||||||
|
<span class="tag">Desktop / Tablet / Mobile</span>
|
||||||
|
<span class="tag">Light + Dark</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 1 — DESIGN TOKENS ════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>1 · Design tokens</h2>
|
||||||
|
<p>All colour values used by the inline pill and its surrounding context. Light and dark themes are shown side by side. Contrast ratios are against the respective surface colour.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pill anatomy callout -->
|
||||||
|
<div class="pill-anatomy">
|
||||||
|
<div class="pill-demo-light">
|
||||||
|
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#012851;font-weight:700">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<div class="pill-demo-dark">
|
||||||
|
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#f0efe9;font-weight:700">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<div class="pill-annotation">
|
||||||
|
<strong>Pill anatomy</strong><br>
|
||||||
|
border-radius: 9999px · padding: 1px 8px<br>
|
||||||
|
font: Montserrat 9px 700 uppercase letter-spacing .07em<br>
|
||||||
|
margin-left: 8px from name span · vertical-align: middle
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="token-grid">
|
||||||
|
<!-- Light -->
|
||||||
|
<div class="token-table light">
|
||||||
|
<div class="token-head">Light theme — surface #ffffff</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Pill bg</td>
|
||||||
|
<td><span class="swatch bordered" style="background:rgba(161,220,216,.25)"></span>rgba(161,220,216,.25) — near-white on white<span class="pass">~14:1 AAA ✓ (text on near-white)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill border</td>
|
||||||
|
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — mint accent outline</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill text</td>
|
||||||
|
<td><span class="swatch" style="background:#012851"></span>#012851 — navy ink<span class="pass">14.5:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person name</td>
|
||||||
|
<td><span class="swatch" style="background:#4b5563"></span>#4b5563 — Montserrat 11px (sub-header)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta person name</td>
|
||||||
|
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 9.5px (metadata drawer)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header bg</td>
|
||||||
|
<td><span class="swatch bordered" style="background:#ffffff"></span>#ffffff</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header border</td>
|
||||||
|
<td><span class="swatch" style="background:#e4e2d7"></span>#e4e2d7</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Arrow (decorative)</td>
|
||||||
|
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — <code>aria-hidden</code><span class="warn">non-text only</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta label</td>
|
||||||
|
<td><span class="swatch" style="background:#6b7280"></span>#6b7280 — Montserrat 9px 700 uppercase<span class="pass">4.8:1 AA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta value</td>
|
||||||
|
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 13px<span class="pass">14.5:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Doc title</td>
|
||||||
|
<td>Tinos serif 18px · #012851</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Avatar KR</td>
|
||||||
|
<td><span class="swatch" style="background:#012851"></span>#012851 — navy</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Avatar HR</td>
|
||||||
|
<td><span class="swatch" style="background:#5a2d6f"></span>#5a2d6f — purple</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- Dark -->
|
||||||
|
<div class="token-table dark">
|
||||||
|
<div class="token-head">Dark theme — surface #011526</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Pill bg</td>
|
||||||
|
<td><span class="swatch bordered" style="background:rgba(0,199,177,.10);border-color:#0d3358"></span>rgba(0,199,177,.10) — dark teal wash<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">passes AA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill border</td>
|
||||||
|
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — turquoise</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill text</td>
|
||||||
|
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — warm white<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person name</td>
|
||||||
|
<td><span class="swatch" style="background:#9ca3af"></span>#9ca3af — (sub-header)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta person name</td>
|
||||||
|
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — (metadata drawer)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header bg</td>
|
||||||
|
<td><span class="swatch" style="background:#011526;border:1px solid #0d3358"></span>#011526</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header border</td>
|
||||||
|
<td><span class="swatch" style="background:#0d3358"></span>#0d3358</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Arrow (decorative)</td>
|
||||||
|
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — <code>aria-hidden</code><span class="warn" style="background:rgba(254,243,199,.1);color:#FDE68A;border:none">non-text only</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta label</td>
|
||||||
|
<td><span class="swatch" style="background:#8b97a5"></span>#8b97a5<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">7.1:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta value</td>
|
||||||
|
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Doc title</td>
|
||||||
|
<td>Tinos serif 18px · #f0efe9</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:10.5px;color:#888;font-style:italic;margin-top:6px">
|
||||||
|
⚠ Pill background rgba(161,220,216,.25) is nearly transparent on white — the effective contrast for the text is calculated against the near-white composite, yielding ~14:1.
|
||||||
|
The arrow between sender and receiver chips in the sub-header is <code>aria-hidden="true"</code> — directional meaning is conveyed by DOM order (sender before receiver) and the visual left-to-right reading order.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 2 — DESKTOP LIGHT & DARK ═════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>2 · Desktop (1280 px) — light & dark</h2>
|
||||||
|
<p>
|
||||||
|
Full document detail page at ~65% scale. Sub-header bar (48 px) shows inline pills next to avatar chips.
|
||||||
|
Metadata drawer is open, showing pills next to person names in the Personen column.
|
||||||
|
Both light and dark themes shown side by side.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split-screens">
|
||||||
|
|
||||||
|
<!-- ── LIGHT ── -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Light theme</div>
|
||||||
|
<div class="chrome">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<!-- App header -->
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
|
||||||
|
<div class="app-link on">Dokumente</div>
|
||||||
|
<div class="app-link">Personen</div>
|
||||||
|
<div class="app-link">Stammbaum</div>
|
||||||
|
<div class="app-link">Admin</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- Sub-header -->
|
||||||
|
<div class="sub-header">
|
||||||
|
<div class="back-btn">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="sh-divider"></div>
|
||||||
|
<div class="sh-doc-title">W-0311 · Divacca</div>
|
||||||
|
<div class="sh-persons">
|
||||||
|
<!-- Sender chip + pill -->
|
||||||
|
<div class="sh-chip">
|
||||||
|
<div class="sh-av" style="background:#012851">KR</div>
|
||||||
|
<span class="sh-name">Karl Raddatz</span>
|
||||||
|
<span class="pill">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<!-- Arrow -->
|
||||||
|
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
|
||||||
|
<!-- Receiver chip + pill -->
|
||||||
|
<div class="sh-chip">
|
||||||
|
<div class="sh-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="sh-name">Hans Raddatz</span>
|
||||||
|
<span class="pill">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sh-actions">
|
||||||
|
<div class="sh-btn-ghost">Details ▾</div>
|
||||||
|
<div class="sh-btn-ghost">Transkribieren</div>
|
||||||
|
<div class="sh-btn-primary">Bearbeiten</div>
|
||||||
|
<div class="sh-btn-icon">
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Metadata drawer -->
|
||||||
|
<div class="meta-drawer">
|
||||||
|
<div class="meta-grid">
|
||||||
|
<!-- Col 1: Details -->
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Details</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Datum</div>
|
||||||
|
<div class="meta-value">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Ort</div>
|
||||||
|
<div class="meta-value">Divacca</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Status</div>
|
||||||
|
<div class="meta-value">Hochgeladen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Col 2: Personen with inline pills -->
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Personen</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Absender</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span class="p-name">Karl Raddatz</span>
|
||||||
|
<span class="pill">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Empfänger</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="p-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="p-name">Hans Raddatz</span>
|
||||||
|
<span class="pill">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Col 3: Tags -->
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Schlagwörter</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||||
|
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- PDF area -->
|
||||||
|
<div class="pdf-area">
|
||||||
|
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Light. Pills appear in both the sub-header chip row and the metadata Personen column. Arrow between chips is mint-coloured and aria-hidden.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DARK ── -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Dark theme</div>
|
||||||
|
<div class="chrome dark">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
|
||||||
|
<div class="app-link on">Dokumente</div>
|
||||||
|
<div class="app-link">Personen</div>
|
||||||
|
<div class="app-link">Stammbaum</div>
|
||||||
|
<div class="app-link">Admin</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="sub-header">
|
||||||
|
<div class="back-btn">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="sh-divider"></div>
|
||||||
|
<div class="sh-doc-title">W-0311 · Divacca</div>
|
||||||
|
<div class="sh-persons">
|
||||||
|
<div class="sh-chip">
|
||||||
|
<div class="sh-av" style="background:#012851">KR</div>
|
||||||
|
<span class="sh-name">Karl Raddatz</span>
|
||||||
|
<span class="pill">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
|
||||||
|
<div class="sh-chip">
|
||||||
|
<div class="sh-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="sh-name">Hans Raddatz</span>
|
||||||
|
<span class="pill">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sh-actions">
|
||||||
|
<div class="sh-btn-ghost">Details ▾</div>
|
||||||
|
<div class="sh-btn-ghost">Transkribieren</div>
|
||||||
|
<div class="sh-btn-primary">Bearbeiten</div>
|
||||||
|
<div class="sh-btn-icon">
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-drawer">
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Details</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Datum</div>
|
||||||
|
<div class="meta-value">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Ort</div>
|
||||||
|
<div class="meta-value">Divacca</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Status</div>
|
||||||
|
<div class="meta-value">Hochgeladen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Personen</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Absender</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span class="p-name">Karl Raddatz</span>
|
||||||
|
<span class="pill">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Empfänger</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="p-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="p-name">Hans Raddatz</span>
|
||||||
|
<span class="pill">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Schlagwörter</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||||
|
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
|
||||||
|
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
|
||||||
|
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Berlin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area">
|
||||||
|
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Dark. Pills flip to rgba(0,199,177,.10) bg, #00c7b1 border, #f0efe9 text. Sub-header and metadata surfaces both use #011526.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 3 — TABLET (768 px) ══════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>3 · Tablet (768 px)</h2>
|
||||||
|
<p>
|
||||||
|
The 3-column metadata grid collapses to a single stacked column. The sub-header truncates the document
|
||||||
|
title and moves secondary actions behind a "…" overflow button. Pills remain inline next to person names in both locations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="responsive-grid">
|
||||||
|
<!-- Tablet light -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Tablet · 768 px · Light</div>
|
||||||
|
<div class="chrome" style="max-width:400px">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- Tablet sub-header: back + title truncated + overflow -->
|
||||||
|
<div class="sub-header-tablet">
|
||||||
|
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
|
||||||
|
<div class="sh-title-truncated">W-0311 · Divacca</div>
|
||||||
|
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||||
|
<div class="sh-btn-primary" style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center">Bearbeiten</div>
|
||||||
|
<div class="sh-overflow-btn">···</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Stacked metadata — Personen section with pills -->
|
||||||
|
<div class="meta-stacked">
|
||||||
|
<div class="meta-section-head">Personen</div>
|
||||||
|
<div style="margin-bottom:6px">
|
||||||
|
<div class="meta-label">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#012851;width:18px;height:18px;font-size:6px">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<div class="meta-label">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#5a2d6f;width:18px;height:18px;font-size:6px">HR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Hans Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-section-head">Details</div>
|
||||||
|
<div class="meta-label">Ort</div>
|
||||||
|
<div class="meta-value">Divacca</div>
|
||||||
|
<div class="meta-label" style="margin-top:6px">Status</div>
|
||||||
|
<div class="meta-value">Hochgeladen</div>
|
||||||
|
<div class="meta-label" style="margin-top:6px">Schlagwörter</div>
|
||||||
|
<div style="display:flex;gap:4px;margin-top:3px">
|
||||||
|
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area" style="min-height:60px">
|
||||||
|
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Tablet light. 3-column metadata collapses to single column. Pills stay inline with names. Sub-header shows only title + primary action + overflow menu.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tablet dark -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Tablet · 768 px · Dark</div>
|
||||||
|
<div class="chrome dark" style="max-width:400px">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="sub-header-tablet" style="background:#011526;border-bottom:1px solid #0d3358">
|
||||||
|
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
|
||||||
|
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||||
|
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center">Bearbeiten</div>
|
||||||
|
<div style="width:22px;height:22px;border:1.5px solid #0d3358;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#8b97a5;font-size:9px;font-weight:700">···</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px 14px;background:#011526;border-bottom:1px solid #0d3358;font-size:9px">
|
||||||
|
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Personen</div>
|
||||||
|
<div style="margin-bottom:6px">
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Hans Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Details</div>
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Ort</div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Status</div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
|
||||||
|
<div style="display:flex;gap:4px;margin-top:3px">
|
||||||
|
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
|
||||||
|
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area" style="min-height:60px">
|
||||||
|
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Tablet dark. Same collapse behaviour. Dark pill tokens apply throughout.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 4 — MOBILE (375 px) ══════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>4 · Mobile (375 px)</h2>
|
||||||
|
<p>
|
||||||
|
Sub-header is simplified to back arrow and document title only — no person chips in the bar.
|
||||||
|
Metadata is full-width single column. Each person row is <code>flex; align-items: center; flex-wrap: nowrap</code>
|
||||||
|
— avatar, name, and pill on one line. If the name is very long the row wraps gracefully before the pill.
|
||||||
|
Only primary action buttons are shown.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="responsive-grid">
|
||||||
|
<!-- Mobile light -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Mobile · 375 px · Light</div>
|
||||||
|
<div class="chrome" style="max-width:260px">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- Mobile sub-header: back + title only -->
|
||||||
|
<div class="sub-header-mobile">
|
||||||
|
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
|
||||||
|
<div class="sh-title-mobile">W-0311 · Divacca</div>
|
||||||
|
<div style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mobile metadata: full-width stacked -->
|
||||||
|
<div class="meta-mobile">
|
||||||
|
<div class="m-label">Absender</div>
|
||||||
|
<div class="person-row-mobile">
|
||||||
|
<div class="p-av-sm" style="background:#012851">KR</div>
|
||||||
|
<span class="p-nm">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-label">Empfänger</div>
|
||||||
|
<div class="person-row-mobile">
|
||||||
|
<div class="p-av-sm" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="p-nm">Hans Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-label">Ort</div>
|
||||||
|
<div class="m-value">Divacca</div>
|
||||||
|
<div class="m-label">Status</div>
|
||||||
|
<div class="m-value">Hochgeladen</div>
|
||||||
|
<div class="m-label">Schlagwörter</div>
|
||||||
|
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
|
||||||
|
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area" style="min-height:60px">
|
||||||
|
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Mobile light. No chips in sub-header — only title + primary action. Person rows: avatar + name + pill, flex-wrap:nowrap. Pill text drops to 6px to fit.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile dark -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Mobile · 375 px · Dark</div>
|
||||||
|
<div class="chrome dark" style="max-width:260px">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="height:48px;background:#011526;border-bottom:1px solid #0d3358;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0">
|
||||||
|
<div style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5;flex-shrink:0">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
|
||||||
|
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:10px 12px;background:#011526;border-bottom:1px solid #0d3358;font-size:8.5px">
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:2px">
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px;margin-top:6px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:8px">
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Hans Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Ort</div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Status</div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
|
||||||
|
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
|
||||||
|
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
|
||||||
|
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area" style="min-height:60px">
|
||||||
|
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Mobile dark. Pill tokens #00c7b1/#f0efe9 at reduced 6px font — still passes AA on dark surface.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 5 — EDGE CASES ════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>5 · Edge cases — when no pill is rendered</h2>
|
||||||
|
<p>Three cases where the pill is silently omitted. The person name renders as normal — no gap, no placeholder.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="edge-grid">
|
||||||
|
|
||||||
|
<!-- Edge 1: no family relationship -->
|
||||||
|
<div class="edge-card">
|
||||||
|
<div class="edge-head">No family relationship → no pill</div>
|
||||||
|
<div class="edge-body">
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
|
||||||
|
<!-- no pill -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#888">ME</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Maria Engel</span>
|
||||||
|
<!-- no pill -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="no-badge">— no pill —</div>
|
||||||
|
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship === null</code> because the backend returns 404 (no kinship path). Name renders without trailing pill.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edge 2: social relationship (Kollegen) → pill shows label -->
|
||||||
|
<div class="edge-card">
|
||||||
|
<div class="edge-head">Social relationship (Kollegen) → pill shows label</div>
|
||||||
|
<div class="edge-body">
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#3d6b5a">FW</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Fritz Weber</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edge-note">Non-family relationships (Kollege, Freund, etc.) returned by the inference endpoint still render as pills. The pill component is label-agnostic — it renders whatever <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship</code> provides.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edge 3: multiple receivers → no pill -->
|
||||||
|
<div class="edge-card">
|
||||||
|
<div class="edge-head">Multiple receivers → no pill</div>
|
||||||
|
<div class="edge-body">
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Hans Raddatz</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#6a7a52">ER</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Elfriede Raddatz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="no-badge">— no pill —</div>
|
||||||
|
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">receivers.length > 1</code> — inference endpoint is never called, <code>inferredRelationship</code> is <code>null</code>. No pill on any person chip.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 6 — IMPLEMENTATION REFERENCE TABLE ═══════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>6 · Implementation reference</h2>
|
||||||
|
<p>Exact CSS/Tailwind values for every element of the pill and its context. Use these as the ground truth during implementation review.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Element</th>
|
||||||
|
<th>Tailwind / CSS</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Inline pill (light)</td>
|
||||||
|
<td><code>rounded-full border border-[#a1dcd8] bg-[rgba(161,220,216,.25)] px-2 py-0.5 text-[9px] font-bold uppercase tracking-[.07em] text-[#012851] ml-2 align-middle inline</code></td>
|
||||||
|
<td>Montserrat 9px 700. <code>ml-2</code> = 8px from name span. <code>vertical-align: middle</code> aligns cap-height to person name.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Inline pill (dark)</td>
|
||||||
|
<td><code>dark:bg-[rgba(0,199,177,.10)] dark:border-[#00c7b1] dark:text-[#f0efe9]</code></td>
|
||||||
|
<td>All three dark overrides applied together. Rest of pill class unchanged.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person name span</td>
|
||||||
|
<td><code>font-sans text-[11px] text-[#4b5563] dark:text-[#9ca3af]</code> (sub-header) or <code>font-serif text-[9.5px] text-ink dark:text-[#f0efe9]</code> (metadata)</td>
|
||||||
|
<td>Name and pill share a <code>flex items-center gap-0</code> wrapper. Pill is the immediate next sibling of the name <code><span></code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header chip area</td>
|
||||||
|
<td><code>flex items-center gap-1.5</code></td>
|
||||||
|
<td>Wraps one sender chip + arrow + one receiver chip. Placed after the doc-title block, before action buttons.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Chip (avatar + name + pill)</td>
|
||||||
|
<td><code>flex items-center gap-1</code></td>
|
||||||
|
<td>Avatar, name span, and pill as three siblings inside the chip div.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Arrow between chips (sub-header)</td>
|
||||||
|
<td><code>h-2.5 w-2.5 shrink-0 text-[#a1dcd8] dark:text-[#00c7b1]</code> with <code>aria-hidden="true"</code></td>
|
||||||
|
<td>Arrow SVG carries no semantic information. DOM order (sender chip before receiver chip) conveys direction for assistive technology.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person avatar (sub-header)</td>
|
||||||
|
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6px] font-bold text-white shrink-0</code></td>
|
||||||
|
<td>20×20 px. Initials in 6px bold white. Background colour is person-specific (set inline).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person avatar (metadata)</td>
|
||||||
|
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6.5px] font-extrabold text-white shrink-0</code></td>
|
||||||
|
<td>Same 20×20 px. Slightly heavier weight (800) to match existing drawer card style.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill condition</td>
|
||||||
|
<td><code>{#if inferredRelationship} … {/if}</code> wraps both the sender pill and the receiver pill</td>
|
||||||
|
<td>Render only when <code>inferredRelationship !== null && receivers.length === 1</code>. The check lives in <code>+page.server.ts</code>, not in the component.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill label value</td>
|
||||||
|
<td><code>inferredRelationship.labelFromA</code> next to sender, <code>inferredRelationship.labelFromB</code> next to receiver</td>
|
||||||
|
<td>Labels are pre-translated strings from the backend. No frontend i18n key needed for the label text itself.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Mobile person row</td>
|
||||||
|
<td><code>flex items-center gap-1 flex-nowrap</code></td>
|
||||||
|
<td><code>flex-wrap: nowrap</code> keeps avatar + name + pill on one line. If name overflows container, truncate name with <code>truncate</code>, never truncate the pill.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Mobile pill font-size</td>
|
||||||
|
<td><code>text-[6px]</code> at ≤375 px</td>
|
||||||
|
<td>Reduced from 9px (desktop) to 6px on mobile to fit without overflow. Contrast still passes AA at 6px bold.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header at mobile</td>
|
||||||
|
<td>Chips removed entirely from sub-header at <code>max-width: 767px</code></td>
|
||||||
|
<td>Sub-header shows only back arrow + document title + primary action button. Person chips with pills appear only in the metadata section on mobile.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:10.5px;color:#888;margin-top:12px;line-height:1.7">
|
||||||
|
<strong style="color:#012851">Accessibility note:</strong> The pill text ("ELTERNTEIL", "KIND") is uppercase visually but the accessible name should be the mixed-case label from the backend (<code>labelFromA</code>). Apply <code>aria-label={labelFromA}</code> on the pill span so screen readers announce "Elternteil" not "E-L-T-E-R-N-T-E-I-L". The visual uppercase is achieved with CSS <code>text-transform: uppercase</code>, not by changing the source string.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div><!-- /doc -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1135
docs/specs/stammbaum-person-edit-spec.html
Normal file
1135
docs/specs/stammbaum-person-edit-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1043
docs/specs/stammbaum-tree-spec.html
Normal file
1043
docs/specs/stammbaum-tree-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ bun.lockb
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
/.svelte-kit/
|
/.svelte-kit/
|
||||||
/.svelte-kit-backup/
|
/.svelte-kit-backup/
|
||||||
|
/.svelte-kit.old/
|
||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
/.svelte-kit-backup/
|
/.svelte-kit-backup/
|
||||||
|
|||||||
197
frontend/CLAUDE.md
Normal file
197
frontend/CLAUDE.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Frontend — Familienarchiv
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
SvelteKit 2 application providing the Familienarchiv web UI. Server-side rendered (SSR) where beneficial, with client-side interactivity for document viewing, transcription, annotation, and admin workflows.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
|
||||||
|
- **Language**: TypeScript 5.9
|
||||||
|
- **Styling**: Tailwind CSS 4.1 + custom brand utilities
|
||||||
|
- **Build Tool**: Vite 7
|
||||||
|
- **Adapter**: `@sveltejs/adapter-node` (Node.js server, not static)
|
||||||
|
- **i18n**: Paraglide.js 2.5 (`@inlang/paraglide-js`) — German (default), English, Spanish
|
||||||
|
- **API Client**: `openapi-fetch` + `openapi-typescript` (generated from backend OpenAPI spec)
|
||||||
|
- **PDF Rendering**: `pdfjs-dist` (PDF.js)
|
||||||
|
- **Testing**:
|
||||||
|
- Unit/Server: Vitest 4 (Node environment)
|
||||||
|
- Component: Vitest Browser Mode with Playwright (Chromium)
|
||||||
|
- E2E: Playwright (`frontend/e2e/`)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── routes/ # SvelteKit file-based routing
|
||||||
|
│ ├── +layout.svelte # Global layout: header, nav, auth state
|
||||||
|
│ ├── +layout.server.ts # Loads current user, injects auth cookie
|
||||||
|
│ ├── +page.svelte # Home / document search dashboard
|
||||||
|
│ ├── documents/ # Document CRUD, detail, edit, upload
|
||||||
|
│ ├── persons/ # Person directory, detail, edit, merge
|
||||||
|
│ ├── briefwechsel/ # Bilateral conversation timeline
|
||||||
|
│ ├── chronik/ # Unified activity feed
|
||||||
|
│ ├── admin/ # User, group, tag, OCR, system management
|
||||||
|
│ ├── api/ # Internal API proxies (server-side only)
|
||||||
|
│ ├── login/ logout/ # Auth pages
|
||||||
|
│ └── ...
|
||||||
|
├── lib/
|
||||||
|
│ ├── components/ # Reusable Svelte components
|
||||||
|
│ │ ├── document/ # Document-specific components
|
||||||
|
│ │ ├── chronik/ # Activity feed components
|
||||||
|
│ │ └── user/ # User-related components
|
||||||
|
│ ├── generated/ # Auto-generated API types (openapi-typescript)
|
||||||
|
│ ├── server/ # Server-only utilities (db, auth helpers)
|
||||||
|
│ ├── services/ # Client-side service logic
|
||||||
|
│ ├── stores/ # Svelte stores (global state)
|
||||||
|
│ ├── types.ts # Shared TypeScript types
|
||||||
|
│ ├── errors.ts # Error code mapping (mirrors backend ErrorCode)
|
||||||
|
│ ├── api.server.ts # Typed API client factory
|
||||||
|
│ ├── utils.ts # Shared utilities
|
||||||
|
│ ├── relativeTime.ts # Time formatting
|
||||||
|
│ ├── search.ts # Search utilities
|
||||||
|
│ └── paraglide/ # Generated i18n code
|
||||||
|
├── hooks/ # SvelteKit hooks (handle, handleFetch)
|
||||||
|
└── actions/ # Custom Svelte actions (click outside, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Client Pattern
|
||||||
|
|
||||||
|
All server-side API calls use the typed client from `$lib/api.server.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
|
||||||
|
|
||||||
|
// Always check via response.ok, NOT result.error
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
return { person: result.data! };
|
||||||
|
```
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
|
||||||
|
- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined)
|
||||||
|
- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code
|
||||||
|
- Use `result.data!` after an ok check
|
||||||
|
|
||||||
|
For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
|
||||||
|
|
||||||
|
## Form Actions Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// +page.server.ts
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
// ...
|
||||||
|
return fail(400, { error: 'message' }); // on error
|
||||||
|
throw redirect(303, '/target'); // on success
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Date Handling
|
||||||
|
|
||||||
|
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO to the backend.
|
||||||
|
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
|
||||||
|
```typescript
|
||||||
|
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
|
||||||
|
new Date(doc.documentDate + 'T12:00:00')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling Conventions (Tailwind CSS 4)
|
||||||
|
|
||||||
|
Brand color utilities (defined in `layout.css`):
|
||||||
|
|
||||||
|
| Class | Value | Usage |
|
||||||
|
| ------------ | --------- | -------------------------------- |
|
||||||
|
| `brand-navy` | `#002850` | Primary text, buttons, headers |
|
||||||
|
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
|
||||||
|
| `brand-sand` | `#E4E2D7` | Page background, card borders |
|
||||||
|
|
||||||
|
Typography:
|
||||||
|
|
||||||
|
- `font-serif` (Merriweather) — body text, document titles, names
|
||||||
|
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
||||||
|
|
||||||
|
Card pattern for content sections:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
||||||
|
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section</h2>
|
||||||
|
<!-- content -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key UI Components
|
||||||
|
|
||||||
|
| Component | Props | Description |
|
||||||
|
| -------------------- | ---------------------------------------------------- | ------------------------------------- |
|
||||||
|
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead |
|
||||||
|
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
|
||||||
|
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
|
||||||
|
| `PdfViewer` | `url`, `annotations`, `on:annotation` | PDF rendering with annotation overlay |
|
||||||
|
| `TranscriptionBlock` | `block`, `mode` | Read/edit transcription block |
|
||||||
|
| `DocumentTopBar` | `document` | Responsive document metadata header |
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # Dev server on port 5173 (or 3000 if --port 3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build & Preview
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Production build
|
||||||
|
npm run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint # Prettier + ESLint check
|
||||||
|
npm run format # Auto-fix formatting
|
||||||
|
npm run check # svelte-check (type checking)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test # Vitest unit + server tests (headless)
|
||||||
|
npm run test:coverage # Coverage report (server project only)
|
||||||
|
npm run test:e2e # Playwright E2E tests
|
||||||
|
npm run test:e2e:headed # Playwright E2E with visible browser
|
||||||
|
npm run test:e2e:ui # Playwright UI mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regenerate API Types
|
||||||
|
|
||||||
|
Requires backend running with `--spring.profiles.active=dev`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vite Proxy
|
||||||
|
|
||||||
|
During development, `/api` calls are proxied to the Spring Boot backend. The proxy injects the `Authorization` header from the `auth_token` cookie automatically (see `vite.config.ts`).
|
||||||
|
|
||||||
|
## i18n (Paraglide)
|
||||||
|
|
||||||
|
Translations live in `messages/{de,en,es}.json`. The compiler generates type-safe helpers in `src/lib/paraglide/`. Run compilation manually with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||||
|
```
|
||||||
|
|
||||||
|
Or let the Vite plugin handle it automatically during dev/build.
|
||||||
141
frontend/e2e/CLAUDE.md
Normal file
141
frontend/e2e/CLAUDE.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# E2E Tests — Familienarchiv
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
End-to-end tests for the Familienarchiv frontend using Playwright. These tests verify complete user flows across the full stack (SvelteKit frontend + Spring Boot backend + PostgreSQL + MinIO).
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Test Runner**: Playwright (`@playwright/test`)
|
||||||
|
- **Browser**: Chromium (desktop)
|
||||||
|
- **Locale**: `de-DE` (ensures German language detection)
|
||||||
|
- **Auth**: Shared session cookie stored after setup
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/e2e/
|
||||||
|
├── auth.setup.ts # Authentication setup — logs in and saves session
|
||||||
|
├── auth.spec.ts # Authentication flows (login, logout, register)
|
||||||
|
├── admin.spec.ts # Admin panel CRUD operations
|
||||||
|
├── annotations.spec.ts # Document annotation features
|
||||||
|
├── bottom-panel.spec.ts # Bottom panel / transcription panel
|
||||||
|
├── dashboard-*.spec.ts # Dashboard variants and screenshots
|
||||||
|
├── documents.spec.ts # Document upload, edit, search
|
||||||
|
├── focus-rings.spec.ts # Accessibility focus ring tests
|
||||||
|
├── header.spec.ts # Navigation header
|
||||||
|
├── history.spec.ts # Chronik / activity feed
|
||||||
|
├── korrespondenz.spec.ts # Correspondence timeline
|
||||||
|
├── lang.spec.ts # Language switching
|
||||||
|
├── password-reset.spec.ts # Password reset flow
|
||||||
|
├── permissions.spec.ts # Role-based access control
|
||||||
|
├── persons.spec.ts # Person directory CRUD
|
||||||
|
├── profile.spec.ts # User profile
|
||||||
|
├── theme.spec.ts # Dark/light mode
|
||||||
|
├── transcription.spec.ts # Transcription workflows
|
||||||
|
├── accessibility.spec.ts # Axe accessibility scans
|
||||||
|
├── fixtures/ # Test data fixtures
|
||||||
|
└── helpers/ # Test helper utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication Strategy
|
||||||
|
|
||||||
|
Tests share auth state via a stored session cookie:
|
||||||
|
|
||||||
|
1. **Setup** (`auth.setup.ts`): Logs in with test credentials and saves `storageState` to `e2e/.auth/user.json`
|
||||||
|
2. **Tests**: All test projects depend on `setup` and reuse the stored session
|
||||||
|
|
||||||
|
This avoids re-logging in for every test, but means tests **must run sequentially** (`fullyParallel: false`, `workers: 1`).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Config lives in `frontend/playwright.config.ts`:
|
||||||
|
|
||||||
|
| Setting | Value | Notes |
|
||||||
|
| --------------- | ----------------------- | ------------------------------ |
|
||||||
|
| `testDir` | `./e2e` | Test file location |
|
||||||
|
| `fullyParallel` | `false` | Shared auth state |
|
||||||
|
| `workers` | `1` | Sequential execution |
|
||||||
|
| `screenshot` | `'on'` | Always capture |
|
||||||
|
| `video` | `'retain-on-failure'` | Keep on failure |
|
||||||
|
| `trace` | `'retain-on-failure'` | Keep on failure |
|
||||||
|
| `baseURL` | `http://localhost:3000` | Overridable via `E2E_BASE_URL` |
|
||||||
|
|
||||||
|
The `webServer` config auto-starts `npm run dev -- --port 3000` if no server is detected at the base URL.
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
The full stack must be running (or the `webServer` config will start the frontend dev server):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start infrastructure
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Ensure backend is healthy
|
||||||
|
curl http://localhost:8080/actuator/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run E2E Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Headless (CI mode)
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# With visible browser
|
||||||
|
npm run test:e2e:headed
|
||||||
|
|
||||||
|
# Interactive UI mode
|
||||||
|
npm run test:e2e:ui
|
||||||
|
|
||||||
|
# Run a specific test file
|
||||||
|
npx playwright test documents.spec.ts
|
||||||
|
|
||||||
|
# Run with a different base URL (e.g., docker frontend on 5173)
|
||||||
|
E2E_BASE_URL=http://localhost:5173 npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New E2E Tests
|
||||||
|
|
||||||
|
1. Create a new `.spec.ts` file in `frontend/e2e/`
|
||||||
|
2. Use the shared auth state (no manual login needed)
|
||||||
|
3. Use page object patterns or helper functions from `helpers/`
|
||||||
|
4. Add `test-data-id` attributes to components for stable selectors
|
||||||
|
5. Run with `--debug` or `--ui` to troubleshoot
|
||||||
|
|
||||||
|
### Example Test Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('user can create a document', async ({ page }) => {
|
||||||
|
await page.goto('/documents/new');
|
||||||
|
await page.getByTestId('document-title').fill('Test Document');
|
||||||
|
await page.getByTestId('save-button').click();
|
||||||
|
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessibility Testing
|
||||||
|
|
||||||
|
`accessibility.spec.ts` runs Axe scans on key pages. Violations fail the test.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test accessibility.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
| --------------------- | ---------------------------------------- |
|
||||||
|
| Auth failures | Delete `e2e/.auth/user.json` and re-run |
|
||||||
|
| Backend not reachable | Ensure `docker-compose up -d` is running |
|
||||||
|
| Flaky tests | Increase timeout or add explicit waits |
|
||||||
|
| Screenshots missing | Check `test-results/e2e/` |
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
E2E tests are **not** currently run in CI (the pipeline stops at unit/component tests). To add them, extend `infra/gitea/workflows/ci.yml` with a Playwright job that starts the full Docker Compose stack first.
|
||||||
75
frontend/e2e/bulk-edit.spec.ts
Normal file
75
frontend/e2e/bulk-edit.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E coverage for the bulk metadata edit feature (issue #225).
|
||||||
|
*
|
||||||
|
* Assumptions:
|
||||||
|
* - Auth setup has run as the admin user (WRITE_ALL).
|
||||||
|
* - The backend exposes /api/documents/{bulk,batch-metadata,ids}.
|
||||||
|
* - At least two documents exist in the search list at /documents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('Bulk metadata edit', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/documents');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checking two documents shows the sticky selection bar with the count', async ({ page }) => {
|
||||||
|
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||||
|
await expect(checkboxes.first()).toBeVisible();
|
||||||
|
await checkboxes.nth(0).check();
|
||||||
|
await checkboxes.nth(1).check();
|
||||||
|
|
||||||
|
const bar = page.getByTestId('bulk-selection-bar');
|
||||||
|
await expect(bar).toBeVisible();
|
||||||
|
await expect(page.getByTestId('bulk-selection-count')).toContainText('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Alles aufheben hides the bar', async ({ page }) => {
|
||||||
|
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||||
|
await checkboxes.nth(0).check();
|
||||||
|
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('bulk-clear-all').click();
|
||||||
|
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Massenbearbeitung navigates to bulk-edit with the selected documents', async ({ page }) => {
|
||||||
|
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||||
|
await checkboxes.nth(0).check();
|
||||||
|
await checkboxes.nth(1).check();
|
||||||
|
await page.getByTestId('bulk-edit-open').click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/documents/bulk-edit');
|
||||||
|
// Onboarding callout is the surest indicator the edit-mode layout rendered.
|
||||||
|
await expect(page.getByTestId('bulk-edit-callout')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating to /documents/bulk-edit with no selection redirects back to /documents', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// Navigate directly without checking anything first.
|
||||||
|
await page.goto('/documents/bulk-edit');
|
||||||
|
await page.waitForURL('**/documents');
|
||||||
|
expect(page.url()).toMatch(/\/documents(\?|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the same selection bar drives the /enrich page', async ({ page }) => {
|
||||||
|
await page.goto('/enrich');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// /enrich may legitimately be empty if every doc has metadata. In that
|
||||||
|
// case there's nothing to bulk-select; skip.
|
||||||
|
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||||
|
const count = await checkboxes.count();
|
||||||
|
test.skip(count === 0, 'No incomplete documents available on /enrich');
|
||||||
|
|
||||||
|
await checkboxes.first().check();
|
||||||
|
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('bulk-selection-count')).toContainText('1');
|
||||||
|
|
||||||
|
await page.getByTestId('bulk-clear-all').click();
|
||||||
|
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,21 +8,27 @@ test.describe('Help chip — Read/Edit panel header', () => {
|
|||||||
docId = await createEmptyDocument(request);
|
docId = await createEmptyDocument(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await request.delete(`/api/documents/${docId}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
|
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
|
||||||
await page.goto(`/documents/${docId}`);
|
await page.goto(`/documents/${docId}`);
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
|
||||||
// Find and click the (?) help chip
|
// Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
|
||||||
const helpBtn = page.locator('button[aria-expanded]');
|
const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
|
||||||
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
||||||
await helpBtn.click();
|
await helpBtn.click();
|
||||||
|
|
||||||
// Popover should open
|
// Popover should open (role="region", not tooltip — click-triggered panels are regions)
|
||||||
await expect(page.locator('[role="tooltip"]')).toBeVisible();
|
await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
|
||||||
|
|
||||||
// Press Esc
|
// Press Esc
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(page.locator('[role="tooltip"]')).not.toBeVisible();
|
await expect(
|
||||||
|
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
// Focus should have returned to the chip
|
// Focus should have returned to the chip
|
||||||
await expect(helpBtn).toBeFocused();
|
await expect(helpBtn).toBeFocused();
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import type { APIRequestContext } from '@playwright/test';
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, '../fixtures/minimal.pdf');
|
||||||
|
|
||||||
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
||||||
const res = await request.post('/api/documents', {
|
const createRes = await request.post('/api/documents', {
|
||||||
multipart: { title: 'E2E Transcribe Coach Test' }
|
multipart: { title: 'E2E Transcribe Coach Test' }
|
||||||
});
|
});
|
||||||
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`);
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
const doc = await res.json();
|
const doc = await createRes.json();
|
||||||
return doc.id as string;
|
const docId = doc.id as string;
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
|
return docId;
|
||||||
}
|
}
|
||||||
|
|||||||
163
frontend/e2e/person-mention-read.spec.ts
Normal file
163
frontend/e2e/person-mention-read.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { test, expect, devices } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
const STORAGE_STATE = path.resolve(__dirname, '.auth/user.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E for issue #362 — Person @mentions, read-mode rendering + hover card (B20/B21).
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - Create a document, a Person, and a transcription block whose text contains
|
||||||
|
* `@DisplayName` and whose mentionedPersons sidecar links to that person.
|
||||||
|
* - Open the document in read mode.
|
||||||
|
* - B20: page.hover() on the .person-mention link → hover card mounts.
|
||||||
|
* - B21: with context.setHasTouch(true), page.tap() on the link → navigates
|
||||||
|
* to /persons/{id} without ever showing the hover card.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let docId: string;
|
||||||
|
let personId: string;
|
||||||
|
let docHref: string;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Person mention — read mode', () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
// 1. Person we will mention.
|
||||||
|
const personRes = await request.post('/api/persons', {
|
||||||
|
data: {
|
||||||
|
firstName: 'Auguste',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
personType: 'PERSON',
|
||||||
|
birthYear: 1882,
|
||||||
|
deathYear: 1944
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!personRes.ok()) throw new Error(`Create person failed: ${personRes.status()}`);
|
||||||
|
const person = await personRes.json();
|
||||||
|
personId = person.id;
|
||||||
|
|
||||||
|
// 2. Document with a PDF so the transcription panel is mountable.
|
||||||
|
// Sara #3: timestamp the title so a previous run that crashed in beforeAll
|
||||||
|
// (and therefore skipped afterAll cleanup) cannot collide with this one.
|
||||||
|
const uniqueSuffix = Date.now();
|
||||||
|
const docRes = await request.post('/api/documents', {
|
||||||
|
multipart: {
|
||||||
|
title: `E2E Person Mention Read ${uniqueSuffix}`,
|
||||||
|
documentDate: '1945-05-08'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||||
|
const doc = await docRes.json();
|
||||||
|
docId = doc.id;
|
||||||
|
docHref = `${baseURL}/documents/${docId}`;
|
||||||
|
|
||||||
|
await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title as string,
|
||||||
|
documentDate: '1945-05-08',
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Annotation to anchor the block on the page.
|
||||||
|
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
|
||||||
|
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
|
||||||
|
});
|
||||||
|
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
|
||||||
|
|
||||||
|
// 4. Block text contains @Auguste Raddatz; sidecar links it to personId.
|
||||||
|
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||||
|
data: {
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.5,
|
||||||
|
height: 0.1,
|
||||||
|
text: 'Brief an @Auguste Raddatz vom Mai 1944',
|
||||||
|
label: null,
|
||||||
|
mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (docId) await request.delete(`/api/documents/${docId}`);
|
||||||
|
if (personId) await request.delete(`/api/persons/${personId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => {
|
||||||
|
await page.goto(docHref);
|
||||||
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||||
|
await expect(link).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(link).toHaveAttribute('href', `/persons/${personId}`);
|
||||||
|
// The @ trigger is stripped from the rendered text per spec
|
||||||
|
await expect(link).toHaveText('Auguste Raddatz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => {
|
||||||
|
await page.goto(docHref);
|
||||||
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||||
|
await link.hover();
|
||||||
|
|
||||||
|
const card = page.getByTestId('person-hover-card');
|
||||||
|
await expect(card).toBeVisible({ timeout: 5000 });
|
||||||
|
// Loaded state: person displayName is rendered inside the card
|
||||||
|
await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz');
|
||||||
|
// Footer link points to /persons/{id}
|
||||||
|
await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('B20: hover card unmounts on mouseleave', async ({ page }) => {
|
||||||
|
await page.goto(docHref);
|
||||||
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||||
|
await link.hover();
|
||||||
|
await expect(page.getByTestId('person-hover-card')).toBeVisible();
|
||||||
|
|
||||||
|
// Move pointer away
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
...devices['Pixel 7'],
|
||||||
|
storageState: STORAGE_STATE
|
||||||
|
});
|
||||||
|
const touchPage = await context.newPage();
|
||||||
|
try {
|
||||||
|
await touchPage.goto(docHref);
|
||||||
|
await touchPage.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||||
|
await expect(link).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Sara #2: assert no card *before* the tap so the test actually proves
|
||||||
|
// the touch device suppression worked, not just that we navigated away.
|
||||||
|
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
|
||||||
|
|
||||||
|
await link.tap();
|
||||||
|
// The card never mounted — the tap navigated directly per spec.
|
||||||
|
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
202
frontend/e2e/person-typeahead.spec.ts
Normal file
202
frontend/e2e/person-typeahead.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* E2E regression tests for PersonTypeahead dropdown visibility.
|
||||||
|
*
|
||||||
|
* These tests verify that the dropdown list is never clipped by a parent
|
||||||
|
* container's stacking context — the root cause of issue #343.
|
||||||
|
*
|
||||||
|
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
|
||||||
|
* as required by the acceptance criteria.
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a document edit URL to use as the test page.
|
||||||
|
* Falls back to /documents/new if no existing document is found.
|
||||||
|
*/
|
||||||
|
async function getDocumentEditUrl(page: Page): Promise<string> {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const firstDocLink = page.locator('a[href^="/documents/"]').first();
|
||||||
|
const href = await firstDocLink.getAttribute('href').catch(() => null);
|
||||||
|
if (href) {
|
||||||
|
return `${href}/edit`;
|
||||||
|
}
|
||||||
|
return '/documents/new';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for the listbox to become visible after triggering a search. */
|
||||||
|
async function waitForListbox(page: Page): Promise<void> {
|
||||||
|
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
|
test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Find the sender typeahead input (the visible text input, not the hidden one)
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await expect(senderInput).toBeVisible();
|
||||||
|
|
||||||
|
// Type to trigger the dropdown
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
|
||||||
|
// Wait for the dropdown to appear (handles debounce automatically)
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
|
||||||
|
const firstOption = dropdown.locator('[role="option"]').first();
|
||||||
|
await expect(firstOption).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the bounding box is within the viewport (not clipped)
|
||||||
|
const box = await firstOption.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box!.y).toBeGreaterThan(0);
|
||||||
|
expect(box!.y + box!.height).toBeLessThan(720);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropdown is positioned below the input field (not hidden behind parent)', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await expect(senderInput).toBeVisible();
|
||||||
|
|
||||||
|
const inputBox = await senderInput.boundingBox();
|
||||||
|
expect(inputBox).not.toBeNull();
|
||||||
|
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
|
||||||
|
const dropdownBox = await dropdown.boundingBox();
|
||||||
|
expect(dropdownBox).not.toBeNull();
|
||||||
|
|
||||||
|
// Dropdown must appear below the input, not on top or clipped behind it
|
||||||
|
expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
|
||||||
|
test.use({ viewport: { width: 768, height: 1024 } });
|
||||||
|
|
||||||
|
test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await expect(senderInput).toBeVisible();
|
||||||
|
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
|
||||||
|
const firstOption = dropdown.locator('[role="option"]').first();
|
||||||
|
await expect(firstOption).toBeVisible();
|
||||||
|
|
||||||
|
const box = await firstOption.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box!.y).toBeGreaterThan(0);
|
||||||
|
expect(box!.y + box!.height).toBeLessThan(1024);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — keyboard navigation', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
|
test('ArrowDown moves focus to the first option', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
await senderInput.press('ArrowDown');
|
||||||
|
// First option should now be the active descendant
|
||||||
|
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
|
||||||
|
expect(activeDescendant).toBeTruthy();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape key closes the dropdown', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
await senderInput.press('Escape');
|
||||||
|
await expect(dropdown).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aria-expanded is true when dropdown is open', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
|
||||||
|
// Initially closed
|
||||||
|
const initialExpanded = await senderInput.getAttribute('aria-expanded');
|
||||||
|
expect(initialExpanded).toBe('false');
|
||||||
|
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const expanded = await senderInput.getAttribute('aria-expanded');
|
||||||
|
expect(expanded).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
|
test('clicking outside a fixed-position dropdown closes it', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
// Click somewhere else on the page
|
||||||
|
await page.click('body', { position: { x: 10, y: 10 } });
|
||||||
|
await expect(dropdown).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,6 +63,12 @@ test.describe('Richtlinien page — print media', () => {
|
|||||||
await expect(nav).toBeHidden();
|
await expect(nav).toBeHidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// .new-tab annotation spans must be hidden in print so "(öffnet in neuem Tab)"
|
||||||
|
// text does not clutter the printed output (the print CSS declares display:none)
|
||||||
|
for (const span of await page.locator('.new-tab').all()) {
|
||||||
|
await expect(span).toBeHidden();
|
||||||
|
}
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
|
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
60
frontend/e2e/stammbaum.spec.ts
Normal file
60
frontend/e2e/stammbaum.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// Tests skipped until Playwright Chromium is installed in CI — see issue #363.
|
||||||
|
test.describe('Stammbaum — issue #358', () => {
|
||||||
|
test.skip();
|
||||||
|
|
||||||
|
test('nav swap: /briefwechsel still renders without 404', async ({ page }) => {
|
||||||
|
// Plan journey 4: the /briefwechsel route must stay intact even though the
|
||||||
|
// AppNav now points at /stammbaum.
|
||||||
|
const response = await page.goto('/briefwechsel');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
await expect(page).toHaveURL(/\/briefwechsel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/stammbaum renders the page heading', async ({ page }) => {
|
||||||
|
await page.goto('/stammbaum');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/stammbaum either shows an empty state or at least one node', async ({ page }) => {
|
||||||
|
// Plan journey 3 (empty branch) and journey 1 (populated branch) covered jointly:
|
||||||
|
// the test passes whenever the page renders one of the two coherent states.
|
||||||
|
await page.goto('/stammbaum');
|
||||||
|
const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' });
|
||||||
|
const anyNode = page.locator('svg[role="img"][aria-label="Stammbaum"] g[role="button"]');
|
||||||
|
await expect(async () => {
|
||||||
|
const emptyVisible = await empty.isVisible().catch(() => false);
|
||||||
|
const nodeCount = await anyNode.count();
|
||||||
|
expect(emptyVisible || nodeCount > 0).toBe(true);
|
||||||
|
}).toPass();
|
||||||
|
|
||||||
|
if (await empty.isVisible().catch(() => false)) {
|
||||||
|
await expect(page.getByRole('link', { name: /Zur Personenliste/ })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('person edit Stammbaum card surfaces the year-range error', async ({ page }) => {
|
||||||
|
// Plan task 36: Bis < Von triggers the inline error and keeps the form unsubmitted.
|
||||||
|
// We pick the first person, open the edit page, expand the add-rel form, and
|
||||||
|
// inspect the validation message bound to the Bis field.
|
||||||
|
await page.goto('/persons');
|
||||||
|
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||||||
|
await firstPerson.click();
|
||||||
|
await expect(page).toHaveURL(/\/persons\/[^/]+/);
|
||||||
|
await page.goto(page.url() + '/edit');
|
||||||
|
|
||||||
|
// Open the add-rel form
|
||||||
|
const addBtn = page.getByRole('button', { name: /Beziehung hinzufügen/i });
|
||||||
|
await addBtn.click();
|
||||||
|
|
||||||
|
// Enter Von 1935, Bis 1920 → expect the year-range error
|
||||||
|
const fromInput = page.locator('input[name="fromYear"]');
|
||||||
|
const toInput = page.locator('input[name="toYear"]');
|
||||||
|
await fromInput.fill('1935');
|
||||||
|
await toInput.fill('1920');
|
||||||
|
|
||||||
|
await expect(page.locator('#add-rel-year-error')).toBeVisible();
|
||||||
|
await expect(page.locator('#add-rel-year-error')).toContainText(/Bis.*Von|nicht vor/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,24 @@ import { test, expect } from '@playwright/test';
|
|||||||
import AxeBuilder from '@axe-core/playwright';
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||||
|
|
||||||
|
async function createBlock(
|
||||||
|
request: Parameters<typeof createEmptyDocument>[0],
|
||||||
|
docId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||||
|
data: {
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.1,
|
||||||
|
text: 'Liebe Mutter,',
|
||||||
|
label: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!res.ok()) throw new Error(`Create block failed: ${res.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||||
}
|
}
|
||||||
@@ -13,10 +31,13 @@ test.describe('Transcribe coach — empty state', () => {
|
|||||||
docId = await createEmptyDocument(request);
|
docId = await createEmptyDocument(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await request.delete(`/api/documents/${docId}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
|
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
|
||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
||||||
await page.goto(`/documents/${docId}`);
|
await page.goto(`/documents/${docId}`);
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
|
||||||
@@ -31,14 +52,12 @@ test.describe('Transcribe coach — empty state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
||||||
await page.goto(`/documents/${docId}`);
|
await page.goto(`/documents/${docId}`);
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
|
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
||||||
await page.goto(`/documents/${docId}`);
|
await page.goto(`/documents/${docId}`);
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||||
@@ -50,10 +69,9 @@ test.describe('Transcribe coach — empty state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
||||||
await page.goto(`/documents/${docId}`);
|
await page.goto(`/documents/${docId}`);
|
||||||
// Toggle dark theme
|
// Toggle dark theme
|
||||||
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
await page.getByRole('button', { name: /dark mode/i }).click();
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
@@ -63,3 +81,25 @@ test.describe('Transcribe coach — empty state', () => {
|
|||||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Transcribe coach — with blocks', () => {
|
||||||
|
let docId: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
docId = await createEmptyDocument(request);
|
||||||
|
await createBlock(request, docId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await request.delete(`/api/documents/${docId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('training footer IS visible when at least one block exists', async ({ page }) => {
|
||||||
|
await page.goto(`/documents/${docId}`);
|
||||||
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
// Wait for blocks to finish loading — block count confirms mode settled to 'read'
|
||||||
|
await expect(page.getByText(/1 Abschnitt/)).toBeVisible({ timeout: 5000 });
|
||||||
|
await page.locator('[data-testid="mode-edit"]').click();
|
||||||
|
await expect(page.getByText('Für Training vormerken')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
{ ignores: ['src/paraglide/**'] },
|
{ ignores: ['src/paraglide/**', '.svelte-kit.old/**'] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs.recommended,
|
||||||
@@ -40,6 +40,26 @@ export default defineConfig(
|
|||||||
parser: ts.parser,
|
parser: ts.parser,
|
||||||
svelteConfig
|
svelteConfig
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// text-accent resolves to #a1dcd8 in light mode (1.52:1 on white — WCAG fail).
|
||||||
|
// layout.css documents it as decorative-only (borders, icon tints, bg fills).
|
||||||
|
// For any text label use text-primary or text-ink instead. This rule catches
|
||||||
|
// the pattern where text-accent appears inside a JavaScript string literal
|
||||||
|
// (e.g. conditional ternary class expressions in Svelte templates).
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'Literal[value=/\\btext-accent\\b/]',
|
||||||
|
message:
|
||||||
|
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'TemplateLiteral > TemplateElement[value.raw=/\\btext-accent\\b/]',
|
||||||
|
message:
|
||||||
|
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Briefwechsel",
|
"nav_conversations": "Briefwechsel",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Abmelden",
|
"nav_logout": "Abmelden",
|
||||||
|
"theme_toggle_to_light": "Zu hellem Design wechseln",
|
||||||
|
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
|
||||||
"btn_save": "Speichern",
|
"btn_save": "Speichern",
|
||||||
"btn_cancel": "Abbrechen",
|
"btn_cancel": "Abbrechen",
|
||||||
"btn_confirm": "Bestätigen",
|
"btn_confirm": "Bestätigen",
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||||
"btn_back": "Zurück",
|
"btn_back": "Zurück",
|
||||||
"btn_back_to_document": "Zurück zum Dokument",
|
"btn_back_to_document": "Zurück zum Dokument",
|
||||||
|
"form_label_person_type": "Typ",
|
||||||
|
"form_label_name": "Name",
|
||||||
"form_label_first_name": "Vorname",
|
"form_label_first_name": "Vorname",
|
||||||
"form_label_last_name": "Nachname",
|
"form_label_last_name": "Nachname",
|
||||||
"form_label_alias": "Rufname / Alias",
|
"form_label_alias": "Rufname / Alias",
|
||||||
@@ -416,6 +420,15 @@
|
|||||||
"notification_unread": "ungelesen",
|
"notification_unread": "ungelesen",
|
||||||
"mention_btn_label": "Person erwähnen",
|
"mention_btn_label": "Person erwähnen",
|
||||||
"mention_popup_empty": "Keine Nutzer gefunden",
|
"mention_popup_empty": "Keine Nutzer gefunden",
|
||||||
|
"person_mention_open_link": "Zur Person",
|
||||||
|
"person_mention_hover_hint": "Klick öffnet Seite",
|
||||||
|
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
||||||
|
"person_mention_loading": "Lade Person…",
|
||||||
|
"person_mention_popup_empty": "Keine Personen gefunden",
|
||||||
|
"person_mention_btn_label": "Person verlinken",
|
||||||
|
"person_mention_create_new": "Neue Person anlegen",
|
||||||
|
"transcription_editor_aria_label": "Transkriptionstext",
|
||||||
|
"person_born_name_prefix": "geb.",
|
||||||
"page_title_home": "Archiv",
|
"page_title_home": "Archiv",
|
||||||
"page_title_persons": "Personen",
|
"page_title_persons": "Personen",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
@@ -489,7 +502,7 @@
|
|||||||
"doc_details_more_receivers": "+{count} weitere",
|
"doc_details_more_receivers": "+{count} weitere",
|
||||||
"transcription_mode_label": "Transkribieren",
|
"transcription_mode_label": "Transkribieren",
|
||||||
"transcription_mode_stop": "Fertig",
|
"transcription_mode_stop": "Fertig",
|
||||||
"transcription_block_placeholder": "Text hier eingeben...",
|
"transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen",
|
||||||
"transcription_block_save_saving": "Speichere...",
|
"transcription_block_save_saving": "Speichere...",
|
||||||
"transcription_block_save_saved": "Gespeichert",
|
"transcription_block_save_saved": "Gespeichert",
|
||||||
"transcription_block_save_error": "Nicht gespeichert",
|
"transcription_block_save_error": "Nicht gespeichert",
|
||||||
@@ -515,7 +528,6 @@
|
|||||||
"scan_collapse": "Scan verkleinern",
|
"scan_collapse": "Scan verkleinern",
|
||||||
"transcription_empty_title": "Noch keine Transkription",
|
"transcription_empty_title": "Noch keine Transkription",
|
||||||
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
|
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
|
||||||
"transcription_empty_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
|
|
||||||
"transcription_panel_close": "Panel schließen",
|
"transcription_panel_close": "Panel schließen",
|
||||||
"person_alias_heading": "Namensverlauf",
|
"person_alias_heading": "Namensverlauf",
|
||||||
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
||||||
@@ -528,6 +540,7 @@
|
|||||||
"person_type_INSTITUTION": "Institution",
|
"person_type_INSTITUTION": "Institution",
|
||||||
"person_type_GROUP": "Gruppe",
|
"person_type_GROUP": "Gruppe",
|
||||||
"person_type_UNKNOWN": "Unbekannt",
|
"person_type_UNKNOWN": "Unbekannt",
|
||||||
|
"a11y_type_changed": "Typ geändert zu {type}",
|
||||||
"person_alias_add_heading": "Name hinzufuegen",
|
"person_alias_add_heading": "Name hinzufuegen",
|
||||||
"person_alias_label_type": "Art",
|
"person_alias_label_type": "Art",
|
||||||
"person_alias_label_last_name": "Nachname",
|
"person_alias_label_last_name": "Nachname",
|
||||||
@@ -537,6 +550,9 @@
|
|||||||
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
||||||
"person_alias_btn_delete": "Entfernen",
|
"person_alias_btn_delete": "Entfernen",
|
||||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||||
|
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||||
|
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
||||||
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
||||||
@@ -811,6 +827,7 @@
|
|||||||
"pagination_next": "Weiter",
|
"pagination_next": "Weiter",
|
||||||
"pagination_page_of": "Seite {page} von {total}",
|
"pagination_page_of": "Seite {page} von {total}",
|
||||||
"pagination_nav_label": "Seitennavigation",
|
"pagination_nav_label": "Seitennavigation",
|
||||||
|
"pagination_page_button": "Seite {page}",
|
||||||
|
|
||||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||||
|
|
||||||
@@ -828,9 +845,9 @@
|
|||||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||||
|
|
||||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal ob Tante Hedwig oder Cousin Paul tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||||
"richtlinien_wiki_text": "Das vollständige Kurrent- und Sütterlin-Alphabet brauchen Sie für diese Seite nicht — das erledigt Wikipedia. Hier sind unsere eigenen Regeln für das, was Wikipedia nicht beantwortet.",
|
"richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
|
||||||
"richtlinien_wiki_link": "Wikipedia →",
|
"richtlinien_wiki_link": "Wikipedia",
|
||||||
"richtlinien_rules_label": "Regeln für die Transkription",
|
"richtlinien_rules_label": "Regeln für die Transkription",
|
||||||
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
|
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
|
||||||
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",
|
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",
|
||||||
@@ -850,5 +867,129 @@
|
|||||||
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
||||||
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
||||||
"richtlinien_closing_title": "Fehlt eine Regel?",
|
"richtlinien_closing_title": "Fehlt eine Regel?",
|
||||||
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen."
|
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen.",
|
||||||
|
"error_batch_too_large": "Zu viele Dateien auf einmal — bitte in Blöcken hochladen.",
|
||||||
|
"bulk_drop_hint": "Eine oder mehrere Dateien ablegen",
|
||||||
|
"bulk_drop_sub": "PDF · bis zu 50 MB pro Datei",
|
||||||
|
"bulk_count_pill": "{count} werden erstellt",
|
||||||
|
"bulk_save_cta_one": "Speichern →",
|
||||||
|
"bulk_save_cta": "{count} speichern →",
|
||||||
|
"bulk_discard_all": "Alle verwerfen",
|
||||||
|
"bulk_discard_confirm": "Alle Dateien und eingegebenen Daten verwerfen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"bulk_add_more": "Weitere hinzufügen",
|
||||||
|
"bulk_scope_per_file_label": "Nur diese Datei",
|
||||||
|
"bulk_scope_shared_label": "Gilt für alle {count}",
|
||||||
|
"bulk_title_suggested_hint": "Vorschlag aus Dateiname — zum Bearbeiten anklicken",
|
||||||
|
"bulk_switcher_prev": "Vorherige Datei",
|
||||||
|
"bulk_switcher_next": "Nächste Datei",
|
||||||
|
"bulk_file_error_chip_label": "Fehler beim Hochladen",
|
||||||
|
"bulk_upload_progress": "{done} von {total} hochgeladen",
|
||||||
|
"bulk_partial_success": "{created} erstellt, {failed} fehlgeschlagen",
|
||||||
|
"bulk_all_failed": "Alle Uploads fehlgeschlagen",
|
||||||
|
"bulk_drop_desc": "Für jede Datei wird ein eigenes Dokument erstellt. Der Titel wird aus dem Dateinamen vorausgefüllt — alle anderen Felder gelten für alle gemeinsam.",
|
||||||
|
"bulk_select_files": "Dateien auswählen",
|
||||||
|
"bulk_drop_zone_label": "Dateien ablegen",
|
||||||
|
"bulk_remove_file": "Entfernen",
|
||||||
|
"bulk_title_single": "Neues Dokument",
|
||||||
|
"bulk_title_multi": "Neue Dokumente",
|
||||||
|
"bulk_edit_button": "Massenbearbeitung",
|
||||||
|
"bulk_edit_n_selected_one": "1 Dokument ausgewählt",
|
||||||
|
"bulk_edit_n_selected_other": "{count} Dokumente ausgewählt",
|
||||||
|
"bulk_edit_clear_all": "Alles aufheben",
|
||||||
|
"bulk_edit_all_x": "Alle {count} editieren",
|
||||||
|
"bulk_edit_select_document": "Dokument {title} auswählen",
|
||||||
|
"bulk_edit_hint": "Nur ausgefüllte Felder werden angewendet. Tags und Empfänger werden hinzugefügt, nicht ersetzt.",
|
||||||
|
"bulk_edit_badge_additive": "+ wird hinzugefügt",
|
||||||
|
"bulk_edit_badge_replace": "wird ersetzt",
|
||||||
|
"bulk_edit_save_progress": "Batch {done} von {total} verarbeitet",
|
||||||
|
"bulk_edit_save_partial": "{done} von {total} gespeichert",
|
||||||
|
"bulk_edit_retry": "Erneut versuchen",
|
||||||
|
"bulk_edit_title": "Massenbearbeitung",
|
||||||
|
"bulk_edit_save_button": "Anwenden",
|
||||||
|
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage.",
|
||||||
|
"form_label_archive_box": "Karton",
|
||||||
|
"form_helper_archive_box": "Welcher Karton im Archiv?",
|
||||||
|
"form_label_archive_folder": "Mappe",
|
||||||
|
"form_helper_archive_folder": "Welche Mappe innerhalb des Kartons?",
|
||||||
|
"bulk_edit_clear_selection": "Auswahl aufheben",
|
||||||
|
"bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben",
|
||||||
|
"bulk_edit_loading": "Dokumente werden geladen…",
|
||||||
|
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
||||||
|
"bulk_edit_topbar_title": "Massenbearbeitung",
|
||||||
|
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||||
|
|
||||||
|
"nav_stammbaum": "Stammbaum",
|
||||||
|
|
||||||
|
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
|
||||||
|
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||||
|
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
|
||||||
|
|
||||||
|
"relation_parent_of": "Elternteil von",
|
||||||
|
"relation_child_of": "Kind von",
|
||||||
|
"relation_spouse_of": "Ehegatte",
|
||||||
|
"relation_sibling_of": "Geschwister",
|
||||||
|
"relation_friend": "Freund",
|
||||||
|
"relation_colleague": "Kollege",
|
||||||
|
"relation_employer": "Arbeitgeber",
|
||||||
|
"relation_doctor": "Arzt",
|
||||||
|
"relation_neighbor": "Nachbar",
|
||||||
|
"relation_other": "Sonstige",
|
||||||
|
|
||||||
|
"relation_inferred_parent": "Elternteil",
|
||||||
|
"relation_inferred_child": "Kind",
|
||||||
|
"relation_inferred_spouse": "Ehegatte",
|
||||||
|
"relation_inferred_sibling": "Geschwister",
|
||||||
|
"relation_inferred_grandparent": "Großelternteil",
|
||||||
|
"relation_inferred_grandchild": "Enkelkind",
|
||||||
|
"relation_inferred_great_grandparent": "Urgroßelternteil",
|
||||||
|
"relation_inferred_great_grandchild": "Urenkel",
|
||||||
|
"relation_inferred_uncle_aunt": "Onkel/Tante",
|
||||||
|
"relation_inferred_niece_nephew": "Nichte/Neffe",
|
||||||
|
"relation_inferred_great_uncle_aunt": "Großonkel/Großtante",
|
||||||
|
"relation_inferred_great_niece_nephew": "Großnichte/Großneffe",
|
||||||
|
"relation_inferred_inlaw_parent": "Schwiegerelternteil",
|
||||||
|
"relation_inferred_inlaw_child": "Schwiegerkind",
|
||||||
|
"relation_inferred_sibling_inlaw": "Schwager/Schwägerin",
|
||||||
|
"relation_inferred_cousin_1": "Cousin/Cousine",
|
||||||
|
"relation_inferred_distant": "Weitläufige Verwandtschaft",
|
||||||
|
|
||||||
|
"doc_details_field_relationship": "Verwandtschaft",
|
||||||
|
|
||||||
|
"stammbaum_empty_heading": "Noch keine Familienmitglieder",
|
||||||
|
"stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.",
|
||||||
|
"stammbaum_empty_link": "→ Zur Personenliste",
|
||||||
|
"stammbaum_panel_direct_rels": "Direkte Beziehungen",
|
||||||
|
"stammbaum_panel_derived_rels": "Abgeleitete Beziehungen",
|
||||||
|
"stammbaum_panel_to_person": "Zur Personenseite →",
|
||||||
|
"stammbaum_panel_add_rel": "+ Beziehung hinzufügen",
|
||||||
|
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
|
||||||
|
"stammbaum_zoom_in": "Vergrößern",
|
||||||
|
"stammbaum_zoom_out": "Verkleinern",
|
||||||
|
"stammbaum_generations": "Generationen",
|
||||||
|
|
||||||
|
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
|
||||||
|
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||||
|
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
|
||||||
|
"relation_year_from": "ab {year}",
|
||||||
|
"relation_year_to": "bis {year}",
|
||||||
|
"relation_year_error_bis_before_von": "Bis-Jahr darf nicht vor Von-Jahr liegen.",
|
||||||
|
"relation_label_family_member": "Als Familienmitglied",
|
||||||
|
"relation_toggle_add_to_tree": "Zum Stammbaum hinzufügen",
|
||||||
|
"relation_toggle_remove_from_tree": "Aus Stammbaum entfernen",
|
||||||
|
"relation_label_in_tree": "Erscheint im Stammbaum",
|
||||||
|
"relation_label_view_in_tree": "Ansehen →",
|
||||||
|
"relation_label_direct": "Direkte Beziehungen",
|
||||||
|
"relation_label_derived": "Abgeleitete Beziehungen",
|
||||||
|
"relation_btn_add": "Hinzufügen",
|
||||||
|
"relation_btn_save": "Speichern",
|
||||||
|
"relation_btn_cancel": "Abbrechen",
|
||||||
|
"relation_form_group_family": "Familie",
|
||||||
|
"relation_form_group_social": "Sozial",
|
||||||
|
"relation_form_field_type": "Typ",
|
||||||
|
"relation_form_field_from_year": "Von Jahr",
|
||||||
|
"relation_form_field_to_year": "Bis Jahr",
|
||||||
|
"relation_form_year_placeholder": "z.B. 1920",
|
||||||
|
|
||||||
|
"person_relationships_heading": "Beziehungen",
|
||||||
|
"person_relationships_empty": "Noch keine Beziehungen bekannt."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Letters",
|
"nav_conversations": "Letters",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Sign out",
|
"nav_logout": "Sign out",
|
||||||
|
"theme_toggle_to_light": "Switch to light mode",
|
||||||
|
"theme_toggle_to_dark": "Switch to dark mode",
|
||||||
"btn_save": "Save",
|
"btn_save": "Save",
|
||||||
"btn_cancel": "Cancel",
|
"btn_cancel": "Cancel",
|
||||||
"btn_confirm": "Confirm",
|
"btn_confirm": "Confirm",
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
"btn_back_to_overview": "Back to overview",
|
"btn_back_to_overview": "Back to overview",
|
||||||
"btn_back": "Back",
|
"btn_back": "Back",
|
||||||
"btn_back_to_document": "Back to document",
|
"btn_back_to_document": "Back to document",
|
||||||
|
"form_label_person_type": "Type",
|
||||||
|
"form_label_name": "Name",
|
||||||
"form_label_first_name": "First name",
|
"form_label_first_name": "First name",
|
||||||
"form_label_last_name": "Last name",
|
"form_label_last_name": "Last name",
|
||||||
"form_label_alias": "Nickname / Alias",
|
"form_label_alias": "Nickname / Alias",
|
||||||
@@ -416,6 +420,15 @@
|
|||||||
"notification_unread": "unread",
|
"notification_unread": "unread",
|
||||||
"mention_btn_label": "Mention person",
|
"mention_btn_label": "Mention person",
|
||||||
"mention_popup_empty": "No users found",
|
"mention_popup_empty": "No users found",
|
||||||
|
"person_mention_open_link": "Open person",
|
||||||
|
"person_mention_hover_hint": "Click opens the page",
|
||||||
|
"person_mention_load_error": "Could not load person.",
|
||||||
|
"person_mention_loading": "Loading person…",
|
||||||
|
"person_mention_popup_empty": "No persons found",
|
||||||
|
"person_mention_btn_label": "Link person",
|
||||||
|
"person_mention_create_new": "Create new person",
|
||||||
|
"transcription_editor_aria_label": "Transcription text",
|
||||||
|
"person_born_name_prefix": "née",
|
||||||
"page_title_home": "Archive",
|
"page_title_home": "Archive",
|
||||||
"page_title_persons": "Persons",
|
"page_title_persons": "Persons",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
@@ -489,7 +502,7 @@
|
|||||||
"doc_details_more_receivers": "+{count} more",
|
"doc_details_more_receivers": "+{count} more",
|
||||||
"transcription_mode_label": "Transcribe",
|
"transcription_mode_label": "Transcribe",
|
||||||
"transcription_mode_stop": "Done",
|
"transcription_mode_stop": "Done",
|
||||||
"transcription_block_placeholder": "Type text here...",
|
"transcription_block_placeholder": "Type text — use @name to link a person from the archive",
|
||||||
"transcription_block_save_saving": "Saving...",
|
"transcription_block_save_saving": "Saving...",
|
||||||
"transcription_block_save_saved": "Saved",
|
"transcription_block_save_saved": "Saved",
|
||||||
"transcription_block_save_error": "Not saved",
|
"transcription_block_save_error": "Not saved",
|
||||||
@@ -515,7 +528,6 @@
|
|||||||
"scan_collapse": "Collapse scan",
|
"scan_collapse": "Collapse scan",
|
||||||
"transcription_empty_title": "No transcription yet",
|
"transcription_empty_title": "No transcription yet",
|
||||||
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
||||||
"transcription_empty_draw_hint": "Draw regions on the document to start transcribing.",
|
|
||||||
"transcription_panel_close": "Close panel",
|
"transcription_panel_close": "Close panel",
|
||||||
"person_alias_heading": "Name history",
|
"person_alias_heading": "Name history",
|
||||||
"person_alias_empty": "No name changes recorded yet.",
|
"person_alias_empty": "No name changes recorded yet.",
|
||||||
@@ -528,6 +540,7 @@
|
|||||||
"person_type_INSTITUTION": "Institution",
|
"person_type_INSTITUTION": "Institution",
|
||||||
"person_type_GROUP": "Group",
|
"person_type_GROUP": "Group",
|
||||||
"person_type_UNKNOWN": "Unknown",
|
"person_type_UNKNOWN": "Unknown",
|
||||||
|
"a11y_type_changed": "Type changed to {type}",
|
||||||
"person_alias_add_heading": "Add name",
|
"person_alias_add_heading": "Add name",
|
||||||
"person_alias_label_type": "Type",
|
"person_alias_label_type": "Type",
|
||||||
"person_alias_label_last_name": "Last name",
|
"person_alias_label_last_name": "Last name",
|
||||||
@@ -537,6 +550,9 @@
|
|||||||
"person_alias_delete_body": "This name will be removed from search results.",
|
"person_alias_delete_body": "This name will be removed from search results.",
|
||||||
"person_alias_btn_delete": "Remove",
|
"person_alias_btn_delete": "Remove",
|
||||||
"error_alias_not_found": "The name alias was not found.",
|
"error_alias_not_found": "The name alias was not found.",
|
||||||
|
"error_invalid_person_type": "The specified person type is not valid.",
|
||||||
|
"validation_last_name_required": "Last name is required.",
|
||||||
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
"error_ocr_job_not_found": "The OCR job was not found.",
|
"error_ocr_job_not_found": "The OCR job was not found.",
|
||||||
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
|
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
|
||||||
@@ -811,6 +827,7 @@
|
|||||||
"pagination_next": "Next",
|
"pagination_next": "Next",
|
||||||
"pagination_page_of": "Page {page} of {total}",
|
"pagination_page_of": "Page {page} of {total}",
|
||||||
"pagination_nav_label": "Pagination",
|
"pagination_nav_label": "Pagination",
|
||||||
|
"pagination_page_button": "Page {page}",
|
||||||
|
|
||||||
"common_opens_new_tab": "(opens in new tab)",
|
"common_opens_new_tab": "(opens in new tab)",
|
||||||
|
|
||||||
@@ -828,9 +845,9 @@
|
|||||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||||
|
|
||||||
"richtlinien_title": "Transcription Guidelines",
|
"richtlinien_title": "Transcription Guidelines",
|
||||||
"richtlinien_intro": "So every letter is transcribed consistently — whether Tante Hedwig or Cousin Paul is typing — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
"richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||||
"richtlinien_wiki_text": "You don't need the full Kurrent and Sütterlin alphabet on this page — that's what Wikipedia is for. Here are our own rules for everything Wikipedia can't answer.",
|
"richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
|
||||||
"richtlinien_wiki_link": "Wikipedia →",
|
"richtlinien_wiki_link": "Wikipedia",
|
||||||
"richtlinien_rules_label": "Transcription rules",
|
"richtlinien_rules_label": "Transcription rules",
|
||||||
"richtlinien_rule_unleserlich_title": "Illegible words",
|
"richtlinien_rule_unleserlich_title": "Illegible words",
|
||||||
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",
|
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",
|
||||||
@@ -850,5 +867,129 @@
|
|||||||
"richtlinien_klaer_umbrueche": "Original line breaks",
|
"richtlinien_klaer_umbrueche": "Original line breaks",
|
||||||
"richtlinien_klaer_caps": "Old capitalisation",
|
"richtlinien_klaer_caps": "Old capitalisation",
|
||||||
"richtlinien_closing_title": "Missing a rule?",
|
"richtlinien_closing_title": "Missing a rule?",
|
||||||
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering."
|
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering.",
|
||||||
|
"error_batch_too_large": "Too many files at once — please upload in smaller batches.",
|
||||||
|
"bulk_drop_hint": "Drop one or more files here",
|
||||||
|
"bulk_drop_sub": "PDF · up to 50 MB per file",
|
||||||
|
"bulk_count_pill": "{count} will be created",
|
||||||
|
"bulk_save_cta_one": "Save →",
|
||||||
|
"bulk_save_cta": "Save {count} →",
|
||||||
|
"bulk_discard_all": "Discard all",
|
||||||
|
"bulk_discard_confirm": "Discard all files and entered data? This action cannot be undone.",
|
||||||
|
"bulk_add_more": "Add more",
|
||||||
|
"bulk_scope_per_file_label": "This file only",
|
||||||
|
"bulk_scope_shared_label": "Applies to all {count}",
|
||||||
|
"bulk_title_suggested_hint": "Suggested from filename — click to edit",
|
||||||
|
"bulk_switcher_prev": "Previous file",
|
||||||
|
"bulk_switcher_next": "Next file",
|
||||||
|
"bulk_file_error_chip_label": "Upload failed",
|
||||||
|
"bulk_upload_progress": "{done} of {total} uploaded",
|
||||||
|
"bulk_partial_success": "{created} created, {failed} failed",
|
||||||
|
"bulk_all_failed": "All uploads failed",
|
||||||
|
"bulk_drop_desc": "A separate document is created for each file. The title is pre-filled from the filename — all other fields apply to all documents.",
|
||||||
|
"bulk_select_files": "Select files",
|
||||||
|
"bulk_drop_zone_label": "Drop files here",
|
||||||
|
"bulk_remove_file": "Remove",
|
||||||
|
"bulk_title_single": "New Document",
|
||||||
|
"bulk_title_multi": "New Documents",
|
||||||
|
"bulk_edit_button": "Bulk edit",
|
||||||
|
"bulk_edit_n_selected_one": "1 document selected",
|
||||||
|
"bulk_edit_n_selected_other": "{count} documents selected",
|
||||||
|
"bulk_edit_clear_all": "Clear all",
|
||||||
|
"bulk_edit_all_x": "Edit all {count}",
|
||||||
|
"bulk_edit_select_document": "Select document {title}",
|
||||||
|
"bulk_edit_hint": "Only filled fields are applied. Tags and receivers are added, not replaced.",
|
||||||
|
"bulk_edit_badge_additive": "+ will be added",
|
||||||
|
"bulk_edit_badge_replace": "will replace",
|
||||||
|
"bulk_edit_save_progress": "Batch {done} of {total} processed",
|
||||||
|
"bulk_edit_save_partial": "{done} of {total} saved",
|
||||||
|
"bulk_edit_retry": "Retry",
|
||||||
|
"bulk_edit_title": "Bulk edit",
|
||||||
|
"bulk_edit_save_button": "Apply",
|
||||||
|
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request.",
|
||||||
|
"form_label_archive_box": "Box",
|
||||||
|
"form_helper_archive_box": "Which box in the archive?",
|
||||||
|
"form_label_archive_folder": "Folder",
|
||||||
|
"form_helper_archive_folder": "Which folder inside the box?",
|
||||||
|
"bulk_edit_clear_selection": "Clear selection",
|
||||||
|
"bulk_edit_clear_hint_keyboard": "Esc: clear selection",
|
||||||
|
"bulk_edit_loading": "Loading documents…",
|
||||||
|
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
||||||
|
"bulk_edit_topbar_title": "Bulk edit",
|
||||||
|
"bulk_edit_count_pill": "{count} will be edited",
|
||||||
|
|
||||||
|
"nav_stammbaum": "Family tree",
|
||||||
|
|
||||||
|
"error_relationship_not_found": "Relationship not found.",
|
||||||
|
"error_circular_relationship": "This relationship would form a cycle.",
|
||||||
|
"error_duplicate_relationship": "This relationship already exists.",
|
||||||
|
|
||||||
|
"relation_parent_of": "Parent of",
|
||||||
|
"relation_child_of": "Child of",
|
||||||
|
"relation_spouse_of": "Spouse",
|
||||||
|
"relation_sibling_of": "Sibling",
|
||||||
|
"relation_friend": "Friend",
|
||||||
|
"relation_colleague": "Colleague",
|
||||||
|
"relation_employer": "Employer",
|
||||||
|
"relation_doctor": "Doctor",
|
||||||
|
"relation_neighbor": "Neighbour",
|
||||||
|
"relation_other": "Other",
|
||||||
|
|
||||||
|
"relation_inferred_parent": "Parent",
|
||||||
|
"relation_inferred_child": "Child",
|
||||||
|
"relation_inferred_spouse": "Spouse",
|
||||||
|
"relation_inferred_sibling": "Sibling",
|
||||||
|
"relation_inferred_grandparent": "Grandparent",
|
||||||
|
"relation_inferred_grandchild": "Grandchild",
|
||||||
|
"relation_inferred_great_grandparent": "Great-grandparent",
|
||||||
|
"relation_inferred_great_grandchild": "Great-grandchild",
|
||||||
|
"relation_inferred_uncle_aunt": "Uncle/Aunt",
|
||||||
|
"relation_inferred_niece_nephew": "Niece/Nephew",
|
||||||
|
"relation_inferred_great_uncle_aunt": "Great-uncle/Aunt",
|
||||||
|
"relation_inferred_great_niece_nephew": "Great-niece/Nephew",
|
||||||
|
"relation_inferred_inlaw_parent": "Parent-in-law",
|
||||||
|
"relation_inferred_inlaw_child": "Child-in-law",
|
||||||
|
"relation_inferred_sibling_inlaw": "Sibling-in-law",
|
||||||
|
"relation_inferred_cousin_1": "Cousin",
|
||||||
|
"relation_inferred_distant": "Distant relative",
|
||||||
|
|
||||||
|
"doc_details_field_relationship": "Relationship",
|
||||||
|
|
||||||
|
"stammbaum_empty_heading": "No family members yet",
|
||||||
|
"stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.",
|
||||||
|
"stammbaum_empty_link": "→ Go to person list",
|
||||||
|
"stammbaum_panel_direct_rels": "Direct relationships",
|
||||||
|
"stammbaum_panel_derived_rels": "Derived relationships",
|
||||||
|
"stammbaum_panel_to_person": "Go to person page →",
|
||||||
|
"stammbaum_panel_add_rel": "+ Add relationship",
|
||||||
|
"stammbaum_relationships_heading": "Family tree & relationships",
|
||||||
|
"stammbaum_zoom_in": "Zoom in",
|
||||||
|
"stammbaum_zoom_out": "Zoom out",
|
||||||
|
"stammbaum_generations": "Generations",
|
||||||
|
|
||||||
|
"relation_error_duplicate": "This relationship already exists.",
|
||||||
|
"relation_error_circular": "This relationship would form a cycle.",
|
||||||
|
"relation_error_self": "A person cannot be related to themselves.",
|
||||||
|
"relation_year_from": "from {year}",
|
||||||
|
"relation_year_to": "until {year}",
|
||||||
|
"relation_year_error_bis_before_von": "End year must not precede start year.",
|
||||||
|
"relation_label_family_member": "Family member",
|
||||||
|
"relation_toggle_add_to_tree": "Add to family tree",
|
||||||
|
"relation_toggle_remove_from_tree": "Remove from family tree",
|
||||||
|
"relation_label_in_tree": "Appears in the family tree",
|
||||||
|
"relation_label_view_in_tree": "View →",
|
||||||
|
"relation_label_direct": "Direct relationships",
|
||||||
|
"relation_label_derived": "Derived relationships",
|
||||||
|
"relation_btn_add": "Add",
|
||||||
|
"relation_btn_save": "Save",
|
||||||
|
"relation_btn_cancel": "Cancel",
|
||||||
|
"relation_form_group_family": "Family",
|
||||||
|
"relation_form_group_social": "Social",
|
||||||
|
"relation_form_field_type": "Type",
|
||||||
|
"relation_form_field_from_year": "From year",
|
||||||
|
"relation_form_field_to_year": "To year",
|
||||||
|
"relation_form_year_placeholder": "e.g. 1920",
|
||||||
|
|
||||||
|
"person_relationships_heading": "Relationships",
|
||||||
|
"person_relationships_empty": "No relationships known yet."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Cartas",
|
"nav_conversations": "Cartas",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Cerrar sesión",
|
"nav_logout": "Cerrar sesión",
|
||||||
|
"theme_toggle_to_light": "Cambiar a modo claro",
|
||||||
|
"theme_toggle_to_dark": "Cambiar a modo oscuro",
|
||||||
"btn_save": "Guardar",
|
"btn_save": "Guardar",
|
||||||
"btn_cancel": "Cancelar",
|
"btn_cancel": "Cancelar",
|
||||||
"btn_confirm": "Confirmar",
|
"btn_confirm": "Confirmar",
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
"btn_back_to_overview": "Volver al resumen",
|
"btn_back_to_overview": "Volver al resumen",
|
||||||
"btn_back": "Volver",
|
"btn_back": "Volver",
|
||||||
"btn_back_to_document": "Volver al documento",
|
"btn_back_to_document": "Volver al documento",
|
||||||
|
"form_label_person_type": "Tipo",
|
||||||
|
"form_label_name": "Nombre",
|
||||||
"form_label_first_name": "Nombre",
|
"form_label_first_name": "Nombre",
|
||||||
"form_label_last_name": "Apellido",
|
"form_label_last_name": "Apellido",
|
||||||
"form_label_alias": "Apodo / Alias",
|
"form_label_alias": "Apodo / Alias",
|
||||||
@@ -416,6 +420,15 @@
|
|||||||
"notification_unread": "no leído",
|
"notification_unread": "no leído",
|
||||||
"mention_btn_label": "Mencionar persona",
|
"mention_btn_label": "Mencionar persona",
|
||||||
"mention_popup_empty": "No se encontraron usuarios",
|
"mention_popup_empty": "No se encontraron usuarios",
|
||||||
|
"person_mention_open_link": "Ir a la persona",
|
||||||
|
"person_mention_hover_hint": "Clic abre la página",
|
||||||
|
"person_mention_load_error": "No se pudo cargar la persona.",
|
||||||
|
"person_mention_loading": "Cargando persona…",
|
||||||
|
"person_mention_popup_empty": "No se encontraron personas",
|
||||||
|
"person_mention_btn_label": "Vincular persona",
|
||||||
|
"person_mention_create_new": "Crear nueva persona",
|
||||||
|
"transcription_editor_aria_label": "Texto de transcripción",
|
||||||
|
"person_born_name_prefix": "n.",
|
||||||
"page_title_home": "Archivo",
|
"page_title_home": "Archivo",
|
||||||
"page_title_persons": "Personas",
|
"page_title_persons": "Personas",
|
||||||
"page_title_admin": "Administración",
|
"page_title_admin": "Administración",
|
||||||
@@ -489,7 +502,7 @@
|
|||||||
"doc_details_more_receivers": "+{count} más",
|
"doc_details_more_receivers": "+{count} más",
|
||||||
"transcription_mode_label": "Transcribir",
|
"transcription_mode_label": "Transcribir",
|
||||||
"transcription_mode_stop": "Listo",
|
"transcription_mode_stop": "Listo",
|
||||||
"transcription_block_placeholder": "Escriba el texto aquí...",
|
"transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo",
|
||||||
"transcription_block_save_saving": "Guardando...",
|
"transcription_block_save_saving": "Guardando...",
|
||||||
"transcription_block_save_saved": "Guardado",
|
"transcription_block_save_saved": "Guardado",
|
||||||
"transcription_block_save_error": "No guardado",
|
"transcription_block_save_error": "No guardado",
|
||||||
@@ -515,7 +528,6 @@
|
|||||||
"scan_collapse": "Reducir escaneo",
|
"scan_collapse": "Reducir escaneo",
|
||||||
"transcription_empty_title": "Sin transcripcion",
|
"transcription_empty_title": "Sin transcripcion",
|
||||||
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
|
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
|
||||||
"transcription_empty_draw_hint": "Dibuje regiones en el documento para comenzar a transcribir.",
|
|
||||||
"transcription_panel_close": "Cerrar panel",
|
"transcription_panel_close": "Cerrar panel",
|
||||||
"person_alias_heading": "Historial de nombres",
|
"person_alias_heading": "Historial de nombres",
|
||||||
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
||||||
@@ -528,6 +540,7 @@
|
|||||||
"person_type_INSTITUTION": "Institución",
|
"person_type_INSTITUTION": "Institución",
|
||||||
"person_type_GROUP": "Grupo",
|
"person_type_GROUP": "Grupo",
|
||||||
"person_type_UNKNOWN": "Desconocido",
|
"person_type_UNKNOWN": "Desconocido",
|
||||||
|
"a11y_type_changed": "Tipo cambiado a {type}",
|
||||||
"person_alias_add_heading": "Agregar nombre",
|
"person_alias_add_heading": "Agregar nombre",
|
||||||
"person_alias_label_type": "Tipo",
|
"person_alias_label_type": "Tipo",
|
||||||
"person_alias_label_last_name": "Apellido",
|
"person_alias_label_last_name": "Apellido",
|
||||||
@@ -537,6 +550,9 @@
|
|||||||
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
||||||
"person_alias_btn_delete": "Eliminar",
|
"person_alias_btn_delete": "Eliminar",
|
||||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||||
|
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||||
|
"validation_last_name_required": "El apellido es obligatorio.",
|
||||||
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
||||||
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
||||||
@@ -811,6 +827,7 @@
|
|||||||
"pagination_next": "Siguiente",
|
"pagination_next": "Siguiente",
|
||||||
"pagination_page_of": "Página {page} de {total}",
|
"pagination_page_of": "Página {page} de {total}",
|
||||||
"pagination_nav_label": "Paginación",
|
"pagination_nav_label": "Paginación",
|
||||||
|
"pagination_page_button": "Página {page}",
|
||||||
|
|
||||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||||
|
|
||||||
@@ -828,9 +845,9 @@
|
|||||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||||
|
|
||||||
"richtlinien_title": "Normas de transcripción",
|
"richtlinien_title": "Normas de transcripción",
|
||||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — ya sea la tía Hedwig o el primo Paul quien escriba — aquí están nuestras reglas. La página crece con nosotros.",
|
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||||
"richtlinien_wiki_text": "No necesitas el alfabeto Kurrent completo aquí — eso lo hace Wikipedia. Aquí están nuestras propias reglas para lo que Wikipedia no responde.",
|
"richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
|
||||||
"richtlinien_wiki_link": "Wikipedia →",
|
"richtlinien_wiki_link": "Wikipedia",
|
||||||
"richtlinien_rules_label": "Reglas de transcripción",
|
"richtlinien_rules_label": "Reglas de transcripción",
|
||||||
"richtlinien_rule_unleserlich_title": "Palabras ilegibles",
|
"richtlinien_rule_unleserlich_title": "Palabras ilegibles",
|
||||||
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",
|
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",
|
||||||
@@ -850,5 +867,129 @@
|
|||||||
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
||||||
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
||||||
"richtlinien_closing_title": "¿Falta una regla?",
|
"richtlinien_closing_title": "¿Falta una regla?",
|
||||||
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar."
|
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar.",
|
||||||
|
"error_batch_too_large": "Demasiados archivos a la vez — sube en lotes más pequeños.",
|
||||||
|
"bulk_drop_hint": "Suelta uno o varios archivos aquí",
|
||||||
|
"bulk_drop_sub": "PDF · hasta 50 MB por archivo",
|
||||||
|
"bulk_count_pill": "Se crearán {count}",
|
||||||
|
"bulk_save_cta_one": "Guardar →",
|
||||||
|
"bulk_save_cta": "Guardar {count} →",
|
||||||
|
"bulk_discard_all": "Descartar todo",
|
||||||
|
"bulk_discard_confirm": "¿Descartar todos los archivos y datos introducidos? Esta acción no se puede deshacer.",
|
||||||
|
"bulk_add_more": "Añadir más",
|
||||||
|
"bulk_scope_per_file_label": "Solo este archivo",
|
||||||
|
"bulk_scope_shared_label": "Para todos los {count}",
|
||||||
|
"bulk_title_suggested_hint": "Sugerencia del nombre de archivo — haz clic para editar",
|
||||||
|
"bulk_switcher_prev": "Archivo anterior",
|
||||||
|
"bulk_switcher_next": "Archivo siguiente",
|
||||||
|
"bulk_file_error_chip_label": "Error al subir",
|
||||||
|
"bulk_upload_progress": "{done} de {total} subidos",
|
||||||
|
"bulk_partial_success": "{created} creados, {failed} fallidos",
|
||||||
|
"bulk_all_failed": "Todos los uploads fallaron",
|
||||||
|
"bulk_drop_desc": "Se crea un documento separado por archivo. El título se rellena desde el nombre del archivo — el resto de campos se aplican a todos.",
|
||||||
|
"bulk_select_files": "Seleccionar archivos",
|
||||||
|
"bulk_drop_zone_label": "Soltar archivos aquí",
|
||||||
|
"bulk_remove_file": "Eliminar",
|
||||||
|
"bulk_title_single": "Nuevo Documento",
|
||||||
|
"bulk_title_multi": "Nuevos Documentos",
|
||||||
|
"bulk_edit_button": "Edición masiva",
|
||||||
|
"bulk_edit_n_selected_one": "1 documento seleccionado",
|
||||||
|
"bulk_edit_n_selected_other": "{count} documentos seleccionados",
|
||||||
|
"bulk_edit_clear_all": "Limpiar todo",
|
||||||
|
"bulk_edit_all_x": "Editar los {count}",
|
||||||
|
"bulk_edit_select_document": "Seleccionar documento {title}",
|
||||||
|
"bulk_edit_hint": "Solo se aplican los campos rellenados. Las etiquetas y los destinatarios se añaden, no se reemplazan.",
|
||||||
|
"bulk_edit_badge_additive": "+ se añade",
|
||||||
|
"bulk_edit_badge_replace": "se reemplaza",
|
||||||
|
"bulk_edit_save_progress": "Lote {done} de {total} procesado",
|
||||||
|
"bulk_edit_save_partial": "{done} de {total} guardado",
|
||||||
|
"bulk_edit_retry": "Reintentar",
|
||||||
|
"bulk_edit_title": "Edición masiva",
|
||||||
|
"bulk_edit_save_button": "Aplicar",
|
||||||
|
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud.",
|
||||||
|
"form_label_archive_box": "Caja",
|
||||||
|
"form_helper_archive_box": "¿Qué caja del archivo?",
|
||||||
|
"form_label_archive_folder": "Carpeta",
|
||||||
|
"form_helper_archive_folder": "¿Qué carpeta dentro de la caja?",
|
||||||
|
"bulk_edit_clear_selection": "Limpiar selección",
|
||||||
|
"bulk_edit_clear_hint_keyboard": "Esc: limpiar selección",
|
||||||
|
"bulk_edit_loading": "Cargando documentos…",
|
||||||
|
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
||||||
|
"bulk_edit_topbar_title": "Edición masiva",
|
||||||
|
"bulk_edit_count_pill": "Se editarán {count}",
|
||||||
|
|
||||||
|
"nav_stammbaum": "Árbol genealógico",
|
||||||
|
|
||||||
|
"error_relationship_not_found": "La relación no fue encontrada.",
|
||||||
|
"error_circular_relationship": "Esta relación crearía un ciclo.",
|
||||||
|
"error_duplicate_relationship": "Esta relación ya existe.",
|
||||||
|
|
||||||
|
"relation_parent_of": "Progenitor de",
|
||||||
|
"relation_child_of": "Hijo/a de",
|
||||||
|
"relation_spouse_of": "Cónyuge",
|
||||||
|
"relation_sibling_of": "Hermano/a",
|
||||||
|
"relation_friend": "Amigo/a",
|
||||||
|
"relation_colleague": "Colega",
|
||||||
|
"relation_employer": "Empleador",
|
||||||
|
"relation_doctor": "Médico",
|
||||||
|
"relation_neighbor": "Vecino/a",
|
||||||
|
"relation_other": "Otro",
|
||||||
|
|
||||||
|
"relation_inferred_parent": "Progenitor",
|
||||||
|
"relation_inferred_child": "Hijo/a",
|
||||||
|
"relation_inferred_spouse": "Cónyuge",
|
||||||
|
"relation_inferred_sibling": "Hermano/a",
|
||||||
|
"relation_inferred_grandparent": "Abuelo/a",
|
||||||
|
"relation_inferred_grandchild": "Nieto/a",
|
||||||
|
"relation_inferred_great_grandparent": "Bisabuelo/a",
|
||||||
|
"relation_inferred_great_grandchild": "Bisnieto/a",
|
||||||
|
"relation_inferred_uncle_aunt": "Tío/Tía",
|
||||||
|
"relation_inferred_niece_nephew": "Sobrino/a",
|
||||||
|
"relation_inferred_great_uncle_aunt": "Tío/a abuelo/a",
|
||||||
|
"relation_inferred_great_niece_nephew": "Sobrino/a nieto/a",
|
||||||
|
"relation_inferred_inlaw_parent": "Suegro/a",
|
||||||
|
"relation_inferred_inlaw_child": "Yerno/Nuera",
|
||||||
|
"relation_inferred_sibling_inlaw": "Cuñado/a",
|
||||||
|
"relation_inferred_cousin_1": "Primo/a",
|
||||||
|
"relation_inferred_distant": "Pariente lejano",
|
||||||
|
|
||||||
|
"doc_details_field_relationship": "Parentesco",
|
||||||
|
|
||||||
|
"stammbaum_empty_heading": "Aún no hay miembros de la familia",
|
||||||
|
"stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.",
|
||||||
|
"stammbaum_empty_link": "→ Ir a la lista de personas",
|
||||||
|
"stammbaum_panel_direct_rels": "Relaciones directas",
|
||||||
|
"stammbaum_panel_derived_rels": "Relaciones derivadas",
|
||||||
|
"stammbaum_panel_to_person": "Ir a la persona →",
|
||||||
|
"stammbaum_panel_add_rel": "+ Añadir relación",
|
||||||
|
"stammbaum_relationships_heading": "Árbol genealógico & relaciones",
|
||||||
|
"stammbaum_zoom_in": "Acercar",
|
||||||
|
"stammbaum_zoom_out": "Alejar",
|
||||||
|
"stammbaum_generations": "Generaciones",
|
||||||
|
|
||||||
|
"relation_error_duplicate": "Esta relación ya existe.",
|
||||||
|
"relation_error_circular": "Esta relación crearía un ciclo.",
|
||||||
|
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
|
||||||
|
"relation_year_from": "desde {year}",
|
||||||
|
"relation_year_to": "hasta {year}",
|
||||||
|
"relation_year_error_bis_before_von": "El año final no puede ser anterior al año inicial.",
|
||||||
|
"relation_label_family_member": "Miembro de la familia",
|
||||||
|
"relation_toggle_add_to_tree": "Añadir al árbol genealógico",
|
||||||
|
"relation_toggle_remove_from_tree": "Quitar del árbol genealógico",
|
||||||
|
"relation_label_in_tree": "Aparece en el árbol genealógico",
|
||||||
|
"relation_label_view_in_tree": "Ver →",
|
||||||
|
"relation_label_direct": "Relaciones directas",
|
||||||
|
"relation_label_derived": "Relaciones derivadas",
|
||||||
|
"relation_btn_add": "Añadir",
|
||||||
|
"relation_btn_save": "Guardar",
|
||||||
|
"relation_btn_cancel": "Cancelar",
|
||||||
|
"relation_form_group_family": "Familia",
|
||||||
|
"relation_form_group_social": "Social",
|
||||||
|
"relation_form_field_type": "Tipo",
|
||||||
|
"relation_form_field_from_year": "Desde año",
|
||||||
|
"relation_form_field_to_year": "Hasta año",
|
||||||
|
"relation_form_year_placeholder": "ej. 1920",
|
||||||
|
|
||||||
|
"person_relationships_heading": "Relaciones",
|
||||||
|
"person_relationships_empty": "Aún no se conocen relaciones."
|
||||||
}
|
}
|
||||||
|
|||||||
553
frontend/package-lock.json
generated
553
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
@@ -2188,6 +2191,403 @@
|
|||||||
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
|
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-mention": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-rGTbTjyxLc5C/6QjfbQF53nMbxjVgJU1VK6Si1i1J2c5DU09COgEFlYvi4YHjb3xz39SprPfG+GTtgD96eg7Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5",
|
||||||
|
"@tiptap/suggestion": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extensions": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
"prosemirror-commands": "^1.6.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-keymap": "^1.2.2",
|
||||||
|
"prosemirror-model": "^1.24.1",
|
||||||
|
"prosemirror-schema-list": "^1.5.0",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-tables": "^1.6.4",
|
||||||
|
"prosemirror-transform": "^1.10.2",
|
||||||
|
"prosemirror-view": "^1.38.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^3.22.5",
|
||||||
|
"@tiptap/extension-blockquote": "^3.22.5",
|
||||||
|
"@tiptap/extension-bold": "^3.22.5",
|
||||||
|
"@tiptap/extension-bullet-list": "^3.22.5",
|
||||||
|
"@tiptap/extension-code": "^3.22.5",
|
||||||
|
"@tiptap/extension-code-block": "^3.22.5",
|
||||||
|
"@tiptap/extension-document": "^3.22.5",
|
||||||
|
"@tiptap/extension-dropcursor": "^3.22.5",
|
||||||
|
"@tiptap/extension-gapcursor": "^3.22.5",
|
||||||
|
"@tiptap/extension-hard-break": "^3.22.5",
|
||||||
|
"@tiptap/extension-heading": "^3.22.5",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^3.22.5",
|
||||||
|
"@tiptap/extension-italic": "^3.22.5",
|
||||||
|
"@tiptap/extension-link": "^3.22.5",
|
||||||
|
"@tiptap/extension-list": "^3.22.5",
|
||||||
|
"@tiptap/extension-list-item": "^3.22.5",
|
||||||
|
"@tiptap/extension-list-keymap": "^3.22.5",
|
||||||
|
"@tiptap/extension-ordered-list": "^3.22.5",
|
||||||
|
"@tiptap/extension-paragraph": "^3.22.5",
|
||||||
|
"@tiptap/extension-strike": "^3.22.5",
|
||||||
|
"@tiptap/extension-text": "^3.22.5",
|
||||||
|
"@tiptap/extension-underline": "^3.22.5",
|
||||||
|
"@tiptap/extensions": "^3.22.5",
|
||||||
|
"@tiptap/pm": "^3.22.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/suggestion": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Uv79Ht/o4mx1GWIT65jeQTE67LMrA+K7d8p51XOe9PJw0H0fS3iCdeMJ8tAo3h6QrMJFejdsB7z8jJL9UbAnhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -4270,6 +4670,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/locate-character": {
|
"node_modules/locate-character": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
@@ -4499,6 +4905,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/p-limit": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
@@ -4934,6 +5346,135 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||||
|
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.41.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||||
|
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.20.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -5044,6 +5585,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sade": {
|
"node_modules/sade": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||||
@@ -5761,6 +6308,12 @@
|
|||||||
"vitest": "^4.0.0"
|
"vitest": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/webpack-virtual-modules": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
|
|||||||
87
frontend/src/lib/actions/radioGroupNav.svelte.spec.ts
Normal file
87
frontend/src/lib/actions/radioGroupNav.svelte.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
const { radioGroupNav } = await import('./radioGroupNav');
|
||||||
|
|
||||||
|
describe('radioGroupNav action', () => {
|
||||||
|
const nodes: HTMLElement[] = [];
|
||||||
|
|
||||||
|
function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.setAttribute('role', 'radiogroup');
|
||||||
|
const buttons: HTMLElement[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.setAttribute('role', 'radio');
|
||||||
|
btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
|
||||||
|
btn.setAttribute('tabindex', i === 0 ? '0' : '-1');
|
||||||
|
container.appendChild(btn);
|
||||||
|
buttons.push(btn);
|
||||||
|
}
|
||||||
|
document.body.appendChild(container);
|
||||||
|
nodes.push(container);
|
||||||
|
return { container, buttons };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nodes.forEach((n) => n.remove());
|
||||||
|
nodes.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowRight moves focus to next button', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowRight wraps from last to first', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[3].focus();
|
||||||
|
buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowLeft moves focus to previous button', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[2].focus();
|
||||||
|
buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowLeft wraps from first to last', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowRight updates aria-checked on new button and removes it from old', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
|
||||||
|
expect(buttons[0].getAttribute('aria-checked')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroy removes keydown listener', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
const { destroy } = radioGroupNav(container);
|
||||||
|
destroy();
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-arrow keys', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
frontend/src/lib/actions/radioGroupNav.ts
Normal file
37
frontend/src/lib/actions/radioGroupNav.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export function radioGroupNav(
|
||||||
|
node: HTMLElement,
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
|
||||||
|
let onChangeFn = onChange;
|
||||||
|
|
||||||
|
function getRadios(): HTMLElement[] {
|
||||||
|
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
|
||||||
|
|
||||||
|
const radios = getRadios();
|
||||||
|
const current = radios.indexOf(document.activeElement as HTMLElement);
|
||||||
|
if (current === -1) return;
|
||||||
|
|
||||||
|
const delta = event.key === 'ArrowRight' ? 1 : -1;
|
||||||
|
const next = (current + delta + radios.length) % radios.length;
|
||||||
|
|
||||||
|
radios[current].setAttribute('aria-checked', 'false');
|
||||||
|
radios[next].setAttribute('aria-checked', 'true');
|
||||||
|
radios[next].focus();
|
||||||
|
onChangeFn?.(radios[next].getAttribute('value') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newOnChange) {
|
||||||
|
onChangeFn = newOnChange;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('keydown', handleKeydown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
202
frontend/src/lib/components/AddRelationshipForm.svelte
Normal file
202
frontend/src/lib/components/AddRelationshipForm.svelte
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||||
|
|
||||||
|
export type RelFormData = {
|
||||||
|
relatedPersonId: string;
|
||||||
|
relationType: RelationType;
|
||||||
|
fromYear?: number;
|
||||||
|
toYear?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
personId: string;
|
||||||
|
onSubmit?: (data: RelFormData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { personId, onSubmit }: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let addType = $state<RelationType>('PARENT_OF');
|
||||||
|
let addRelatedPersonId = $state('');
|
||||||
|
let addRelatedPersonName = $state('');
|
||||||
|
let addFromYear = $state('');
|
||||||
|
let addToYear = $state('');
|
||||||
|
let callbackError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const yearError = $derived.by(() => {
|
||||||
|
const from = addFromYear.trim();
|
||||||
|
const to = addToYear.trim();
|
||||||
|
if (!from || !to) return null;
|
||||||
|
const fromInt = parseInt(from, 10);
|
||||||
|
const toInt = parseInt(to, 10);
|
||||||
|
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
|
||||||
|
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selfError = $derived(
|
||||||
|
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitDisabled = $derived(
|
||||||
|
yearError !== null || selfError !== null || addRelatedPersonId === ''
|
||||||
|
);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
addType = 'PARENT_OF';
|
||||||
|
addRelatedPersonId = '';
|
||||||
|
addRelatedPersonName = '';
|
||||||
|
addFromYear = '';
|
||||||
|
addToYear = '';
|
||||||
|
callbackError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
open = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCallbackSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitDisabled || !onSubmit) return;
|
||||||
|
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
|
||||||
|
const from = parseInt(addFromYear.trim(), 10);
|
||||||
|
if (!Number.isNaN(from)) data.fromYear = from;
|
||||||
|
const to = parseInt(addToYear.trim(), 10);
|
||||||
|
if (!Number.isNaN(to)) data.toYear = to;
|
||||||
|
try {
|
||||||
|
await onSubmit(data);
|
||||||
|
open = false;
|
||||||
|
reset();
|
||||||
|
} catch {
|
||||||
|
callbackError = m.error_internal_error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet formFields()}
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_type()}</span>
|
||||||
|
<select
|
||||||
|
name="relationType"
|
||||||
|
bind:value={addType}
|
||||||
|
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||||
|
>
|
||||||
|
<optgroup label={m.relation_form_group_family()}>
|
||||||
|
<option value="PARENT_OF">{m.relation_parent_of()}</option>
|
||||||
|
<option value="SPOUSE_OF">{m.relation_spouse_of()}</option>
|
||||||
|
<option value="SIBLING_OF">{m.relation_sibling_of()}</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label={m.relation_form_group_social()}>
|
||||||
|
<option value="FRIEND">{m.relation_friend()}</option>
|
||||||
|
<option value="COLLEAGUE">{m.relation_colleague()}</option>
|
||||||
|
<option value="EMPLOYER">{m.relation_employer()}</option>
|
||||||
|
<option value="DOCTOR">{m.relation_doctor()}</option>
|
||||||
|
<option value="NEIGHBOR">{m.relation_neighbor()}</option>
|
||||||
|
<option value="OTHER">{m.relation_other()}</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<PersonTypeahead
|
||||||
|
name="relatedPersonId"
|
||||||
|
label="Person"
|
||||||
|
bind:value={addRelatedPersonId}
|
||||||
|
initialName={addRelatedPersonName}
|
||||||
|
excludePersonId={personId}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-sans text-xs font-medium text-ink-2"
|
||||||
|
>{m.relation_form_field_from_year()}</span
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="fromYear"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
bind:value={addFromYear}
|
||||||
|
placeholder={m.relation_form_year_placeholder()}
|
||||||
|
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="toYear"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
bind:value={addToYear}
|
||||||
|
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
|
||||||
|
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
{#if yearError}
|
||||||
|
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
|
||||||
|
{yearError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if selfError}
|
||||||
|
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if callbackError}
|
||||||
|
<p class="mt-2 text-xs text-red-700" role="alert">{callbackError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancel}
|
||||||
|
class="rounded-sm border border-line bg-surface px-3 py-1.5 font-sans text-xs font-medium text-ink-2 transition hover:bg-muted"
|
||||||
|
>
|
||||||
|
{m.relation_btn_cancel()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitDisabled}
|
||||||
|
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{m.relation_btn_add()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if !open}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (open = true)}
|
||||||
|
class="mt-2 inline-flex items-center gap-1 font-sans text-xs font-medium text-ink-2 hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.stammbaum_panel_add_rel()}
|
||||||
|
</button>
|
||||||
|
{:else if onSubmit}
|
||||||
|
<form onsubmit={handleCallbackSubmit} class="mt-3 rounded-sm border border-line bg-muted/40 p-3">
|
||||||
|
{@render formFields()}
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addRelationship"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') {
|
||||||
|
open = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
|
||||||
|
>
|
||||||
|
{@render formFields()}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import AddRelationshipForm from './AddRelationshipForm.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||||
|
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('AddRelationshipForm', () => {
|
||||||
|
it('shows add-relationship button initially and no form', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows relationType select when add button is clicked', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides form and shows button when cancel is clicked', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
|
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
|
||||||
|
);
|
||||||
|
cancelBtn!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submit is disabled when no person is selected', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('form has no server action when onSubmit prop is provided', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
expect(form?.hasAttribute('action')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows year-range error when toYear is before fromYear', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
|
||||||
|
fromInput.value = '1935';
|
||||||
|
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
|
||||||
|
toInput.value = '1920';
|
||||||
|
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,8 @@ let {
|
|||||||
dimmed = false,
|
dimmed = false,
|
||||||
flashAnnotationId = null,
|
flashAnnotationId = null,
|
||||||
onDraw,
|
onDraw,
|
||||||
onAnnotationClick
|
onAnnotationClick,
|
||||||
|
onDeleteRequest
|
||||||
}: {
|
}: {
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
canDraw: boolean;
|
canDraw: boolean;
|
||||||
@@ -29,6 +30,7 @@ let {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
onDraw: (rect: DrawRect) => void;
|
onDraw: (rect: DrawRect) => void;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
|
onDeleteRequest?: (annotationId: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let drawStart = $state<{ x: number; y: number } | null>(null);
|
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||||||
@@ -112,6 +114,8 @@ const containerStyle = $derived(
|
|||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
blockNumber={blockNumbers[annotation.id]}
|
blockNumber={blockNumbers[annotation.id]}
|
||||||
isFlashing={flashAnnotationId === annotation.id}
|
isFlashing={flashAnnotationId === annotation.id}
|
||||||
|
showDelete={canDraw}
|
||||||
|
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
|
||||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||||
onpointerenter={() => (hoveredId = annotation.id)}
|
onpointerenter={() => (hoveredId = annotation.id)}
|
||||||
onpointerleave={() => (hoveredId = null)}
|
onpointerleave={() => (hoveredId = null)}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
|
|||||||
expect(el2.style.opacity).toBe('1');
|
expect(el2.style.opacity).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show delete buttons (annotations owned by blocks)', async () => {
|
it('does not show delete button when annotation is not hovered or active', async () => {
|
||||||
render(AnnotationLayer, {
|
render(AnnotationLayer, {
|
||||||
annotations: [makeAnnotation('ann-1')],
|
annotations: [makeAnnotation('ann-1')],
|
||||||
canDraw: true,
|
canDraw: true,
|
||||||
@@ -107,6 +107,19 @@ describe('AnnotationLayer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when canDraw is false even if annotation is active', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [makeAnnotation('ann-1')],
|
||||||
|
canDraw: false,
|
||||||
|
color: '#00C7B1',
|
||||||
|
activeAnnotationId: 'ann-1',
|
||||||
|
onDraw: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ let {
|
|||||||
blockNumber = undefined,
|
blockNumber = undefined,
|
||||||
isFlashing = false,
|
isFlashing = false,
|
||||||
isResizable = false,
|
isResizable = false,
|
||||||
|
showDelete = false,
|
||||||
|
onDeleteRequest,
|
||||||
onclick,
|
onclick,
|
||||||
onpointerenter,
|
onpointerenter,
|
||||||
onpointerleave
|
onpointerleave
|
||||||
@@ -23,11 +25,15 @@ let {
|
|||||||
blockNumber?: number | undefined;
|
blockNumber?: number | undefined;
|
||||||
isFlashing?: boolean;
|
isFlashing?: boolean;
|
||||||
isResizable?: boolean;
|
isResizable?: boolean;
|
||||||
|
showDelete?: boolean;
|
||||||
|
onDeleteRequest?: () => void;
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
onpointerenter: () => void;
|
onpointerenter: () => void;
|
||||||
onpointerleave: () => void;
|
onpointerleave: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const deleteVisible = $derived(showDelete && (isHovered || isActive));
|
||||||
|
|
||||||
function hexToRgba(hex: string, alpha: number): string {
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
const g = parseInt(hex.slice(3, 5), 16);
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
@@ -83,6 +89,7 @@ let shapeStyle = $derived(
|
|||||||
onclick={onclick}
|
onclick={onclick}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') onclick();
|
if (e.key === 'Enter' || e.key === ' ') onclick();
|
||||||
|
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
|
||||||
}}
|
}}
|
||||||
onpointerenter={onpointerenter}
|
onpointerenter={onpointerenter}
|
||||||
onpointerleave={onpointerleave}
|
onpointerleave={onpointerleave}
|
||||||
@@ -112,6 +119,51 @@ let shapeStyle = $derived(
|
|||||||
{blockNumber}
|
{blockNumber}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if deleteVisible}
|
||||||
|
<button
|
||||||
|
data-testid="annotation-delete-{annotation.id}"
|
||||||
|
type="button"
|
||||||
|
aria-label="Löschen"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteRequest?.();
|
||||||
|
}}
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid var(--color-error, #e53e3e);
|
||||||
|
color: var(--color-error, #e53e3e);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
||||||
|
z-index: 10;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if isResizable}
|
{#if isResizable}
|
||||||
<AnnotationEditOverlay annotation={annotation} />
|
<AnnotationEditOverlay annotation={annotation} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
177
frontend/src/lib/components/AnnotationShape.svelte.spec.ts
Normal file
177
frontend/src/lib/components/AnnotationShape.svelte.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import AnnotationShape from './AnnotationShape.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function makeAnnotation(id = 'ann-1') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
documentId: 'doc-1',
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.2,
|
||||||
|
color: '#00C7B1',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnnotationShape', () => {
|
||||||
|
it('renders the annotation element', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: false,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when showDelete is false', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: false,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button when showDelete is true and isHovered is true', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button when showDelete is true and isActive is true', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDeleteRequest when delete button is clicked', async () => {
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
||||||
|
await deleteBtn.click();
|
||||||
|
|
||||||
|
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onclick when delete button is clicked', async () => {
|
||||||
|
const onclick = vi.fn();
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick,
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
||||||
|
await deleteBtn.click();
|
||||||
|
|
||||||
|
expect(onclick).not.toHaveBeenCalled();
|
||||||
|
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
||||||
|
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
showDelete: false,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
||||||
|
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onDeleteRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||||
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||||
|
import RelationshipPill from '$lib/components/RelationshipPill.svelte';
|
||||||
|
|
||||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
type Tag = { id: string; name: string };
|
type Tag = { id: string; name: string };
|
||||||
@@ -14,9 +15,18 @@ type Props = {
|
|||||||
sender: Person | null;
|
sender: Person | null;
|
||||||
receivers: Person[];
|
receivers: Person[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
|
let {
|
||||||
|
documentDate,
|
||||||
|
location,
|
||||||
|
status,
|
||||||
|
sender,
|
||||||
|
receivers,
|
||||||
|
tags,
|
||||||
|
inferredRelationship = null
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const VISIBLE_RECEIVER_LIMIT = 5;
|
const VISIBLE_RECEIVER_LIMIT = 5;
|
||||||
|
|
||||||
@@ -37,7 +47,7 @@ function getFullName(person: Person): string {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet personCard(person: Person)}
|
{#snippet personCard(person: Person, relationLabel: string | null = null)}
|
||||||
<a
|
<a
|
||||||
href="/persons/{person.id}"
|
href="/persons/{person.id}"
|
||||||
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
||||||
@@ -49,7 +59,10 @@ function getFullName(person: Person): string {
|
|||||||
>
|
>
|
||||||
{getInitials(person.displayName)}
|
{getInitials(person.displayName)}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||||
|
{#if relationLabel}
|
||||||
|
<RelationshipPill label={relationLabel} />
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -88,7 +101,7 @@ function getFullName(person: Person): string {
|
|||||||
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||||
{m.doc_details_field_sender()}
|
{m.doc_details_field_sender()}
|
||||||
</p>
|
</p>
|
||||||
{@render personCard(sender)}
|
{@render personCard(sender, inferredRelationship?.labelFromA ?? null)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if receivers.length > 0}
|
{#if receivers.length > 0}
|
||||||
@@ -97,8 +110,16 @@ function getFullName(person: Person): string {
|
|||||||
{m.doc_details_field_receivers()}
|
{m.doc_details_field_receivers()}
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
{#each displayedReceivers as receiver (receiver.id)}
|
{#each displayedReceivers as receiver, i (receiver.id)}
|
||||||
{@render personCard(receiver)}
|
{@render personCard(
|
||||||
|
receiver,
|
||||||
|
// Badge only shown when there is exactly one receiver — with multiple
|
||||||
|
// receivers the inferred label is computed from the sender's viewpoint
|
||||||
|
// and cannot be attributed to a specific receiver.
|
||||||
|
i === 0 && receivers.length === 1
|
||||||
|
? (inferredRelationship?.labelFromB ?? null)
|
||||||
|
: null
|
||||||
|
)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
||||||
|
|||||||
@@ -81,6 +81,25 @@ describe('DocumentMetadataDrawer — persons column', () => {
|
|||||||
renderDrawer({ sender: null, receivers: [] });
|
renderDrawer({ sender: null, receivers: [] });
|
||||||
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
|
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders inferred relationship pills inline next to sender and receiver', async () => {
|
||||||
|
renderDrawer({
|
||||||
|
receivers: [receivers[0]],
|
||||||
|
inferredRelationship: { labelFromA: 'Elternteil', labelFromB: 'Kind' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sender link contains its pill, receiver link contains its pill.
|
||||||
|
const senderLink = page.getByRole('link', { name: /Karl Müller.*Elternteil/i });
|
||||||
|
await expect.element(senderLink).toBeInTheDocument();
|
||||||
|
const receiverLink = page.getByRole('link', { name: /Anna Schmidt.*Kind/i });
|
||||||
|
await expect.element(receiverLink).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the pills when no inferred relationship is provided', async () => {
|
||||||
|
renderDrawer();
|
||||||
|
const elternteil = page.getByText('Elternteil');
|
||||||
|
expect(await elternteil.elements()).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Tags column ─────────────────────────────────────────────────────────────
|
// ─── Tags column ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user