Compare commits
195 Commits
94e976bae3
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c2c83996 | ||
|
|
c317c085aa | ||
|
|
bc805cb178 | ||
|
|
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 | ||
|
|
c3fac5b0ad | ||
|
|
03b180fe88 | ||
|
|
b234db0472 | ||
|
|
7c3a8e7651 | ||
|
|
7fb9d74515 | ||
|
|
dff203d526 | ||
|
|
86584a53a8 | ||
|
|
1d5219eac4 | ||
|
|
6e021fb23a | ||
|
|
bdac5e42ad | ||
|
|
18b88672ec | ||
|
|
8fa061187e | ||
|
|
610915b2a2 | ||
|
|
78ac5d663d | ||
|
|
826c0827dc | ||
|
|
7a75ffed76 | ||
|
|
1299bd5938 | ||
|
|
8f28a99e00 | ||
|
|
7007491d8c | ||
|
|
629f0183f7 | ||
|
|
72cd6f5bbc | ||
|
|
1d44bbb1bd | ||
|
|
a02f6cdcd7 | ||
|
|
817749889a | ||
|
|
a8b9133b80 | ||
|
|
510ab1d2d5 | ||
|
|
ad999c47ea | ||
|
|
9862a51ac7 | ||
|
|
df260d5c64 | ||
|
|
096f66eb15 | ||
|
|
0b33f323ee | ||
|
|
334b624063 | ||
|
|
503ce49ef7 | ||
|
|
f5a30c71b7 | ||
|
|
720f90299a | ||
|
|
0e988a9d42 | ||
|
|
8cb179a8a1 | ||
|
|
05c1bf750a | ||
|
|
a7ab5e6e69 | ||
|
|
24b2dc0460 | ||
|
|
9ecf7f4dfc | ||
|
|
01bfc59849 | ||
|
|
03616f0728 | ||
|
|
7090f9a0e0 | ||
|
|
d4617a96d1 | ||
|
|
b9dda9a938 | ||
|
|
d6b1949c84 | ||
|
|
c16a9ca602 | ||
|
|
30e301830a | ||
|
|
4b893b4808 | ||
|
|
df681be626 | ||
|
|
cc118ffb16 | ||
|
|
407bfbd5f1 | ||
|
|
a52d481a8e | ||
|
|
70d813ee70 | ||
|
|
d99f4544d2 | ||
|
|
22ce705bb0 | ||
|
|
e6d55e47b1 | ||
|
|
b48533be26 | ||
|
|
7fc517b787 | ||
|
|
8ac996f6b2 | ||
|
|
55557047de |
@@ -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,6 +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.Min;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.raddatz.familienarchiv.dto.BatchMetadataRequest;
|
||||||
|
import org.raddatz.familienarchiv.dto.BulkEditError;
|
||||||
|
import org.raddatz.familienarchiv.dto.BulkEditResult;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
@@ -62,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
@RequestMapping("/api/documents")
|
@RequestMapping("/api/documents")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@Validated
|
||||||
public class DocumentController {
|
public class DocumentController {
|
||||||
|
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
@@ -187,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<>();
|
||||||
@@ -196,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 {
|
||||||
@@ -215,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() {
|
||||||
@@ -252,14 +372,20 @@ public class DocumentController {
|
|||||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
||||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
|
||||||
|
// @Max on page guards against overflow when pageable.getOffset() is computed
|
||||||
|
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
|
||||||
|
// Hibernate cheerfully turns into an invalid SQL OFFSET.
|
||||||
|
@Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
|
||||||
|
@Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
|
||||||
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||||
}
|
}
|
||||||
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
||||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator));
|
Pageable pageable = PageRequest.of(page, size);
|
||||||
|
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TRAINING LABELS ---
|
// --- TRAINING LABELS ---
|
||||||
|
|||||||
@@ -63,27 +63,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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ public class DashboardService {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
|
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
|
||||||
totalBlocks, pct, null, collaborators);
|
totalBlocks, pct, doc.getThumbnailUrl(), collaborators);
|
||||||
}
|
}
|
||||||
|
|
||||||
public DashboardPulseDTO getPulse(UUID userId) {
|
public DashboardPulseDTO getPulse(UUID userId) {
|
||||||
|
|||||||
@@ -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) {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -8,9 +9,30 @@ public record DocumentSearchResult(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
List<DocumentSearchItem> items,
|
List<DocumentSearchItem> items,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
long total
|
long totalElements,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int pageNumber,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int pageSize,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int totalPages
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
||||||
|
* don't care about paging. Treats the whole list as page 0 of itself.
|
||||||
|
*/
|
||||||
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
||||||
return new DocumentSearchResult(items, items.size());
|
int size = items.size();
|
||||||
|
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paged factory used by the service when it has a real Pageable + full match count
|
||||||
|
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||||
|
*/
|
||||||
|
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
|
||||||
|
int pageSize = pageable.getPageSize();
|
||||||
|
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||||
|
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -13,6 +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 */
|
||||||
@@ -109,6 +111,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -50,6 +53,13 @@ public class Document {
|
|||||||
@Column(name = "thumbnail_generated_at")
|
@Column(name = "thumbnail_generated_at")
|
||||||
private LocalDateTime thumbnailGeneratedAt;
|
private LocalDateTime thumbnailGeneratedAt;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "thumbnail_aspect", length = 16)
|
||||||
|
private ThumbnailAspect thumbnailAspect;
|
||||||
|
|
||||||
|
@Column(name = "page_count")
|
||||||
|
private Integer pageCount;
|
||||||
|
|
||||||
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||||
@Column(name = "original_filename", nullable = false)
|
@Column(name = "original_filename", nullable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@@ -124,4 +134,19 @@ public class Document {
|
|||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||||
|
|
||||||
|
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
||||||
|
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
||||||
|
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
||||||
|
// this URL changes whenever the underlying file does. Dropping the query param
|
||||||
|
// would let browsers serve a stale thumbnail for a year after the file is
|
||||||
|
// replaced, and shared caches could leak one user's thumbnail to another
|
||||||
|
// (CWE-525).
|
||||||
|
@JsonProperty("thumbnailUrl")
|
||||||
|
public String getThumbnailUrl() {
|
||||||
|
if (thumbnailKey == null) return null;
|
||||||
|
String base = "/api/documents/" + id + "/thumbnail";
|
||||||
|
if (thumbnailGeneratedAt == null) return base;
|
||||||
|
return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
public enum ThumbnailAspect {
|
||||||
|
PORTRAIT,
|
||||||
|
LANDSCAPE
|
||||||
|
}
|
||||||
@@ -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'':*',
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -22,7 +25,9 @@ import org.raddatz.familienarchiv.model.TrainingLabel;
|
|||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -130,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())
|
||||||
@@ -220,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()) {
|
||||||
@@ -285,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -355,7 +531,7 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) {
|
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
|
||||||
boolean hasText = StringUtils.hasText(text);
|
boolean hasText = StringUtils.hasText(text);
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
|
|
||||||
@@ -364,27 +540,21 @@ 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;
|
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
||||||
Specification<Document> spec = Specification.where(textSpec)
|
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||||
.and(isBetween(from, to))
|
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
|
||||||
.and(hasSender(sender))
|
// rank list. Cost scales linearly with match count — acceptable while documents
|
||||||
.and(hasReceiver(receiver))
|
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
|
||||||
.and(hasTags(expandedTagSets, useOrLogic))
|
|
||||||
.and(hasTagPartial(tagQ))
|
|
||||||
.and(hasStatus(status));
|
|
||||||
|
|
||||||
// SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName")
|
|
||||||
// generates an INNER JOIN that silently drops documents with null sender/receivers.
|
|
||||||
if (sort == DocumentSort.RECEIVER) {
|
if (sort == DocumentSort.RECEIVER) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
||||||
return buildResult(sortByFirstReceiver(results, dir), text);
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
if (sort == DocumentSort.SENDER) {
|
if (sort == DocumentSort.SENDER) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
||||||
return buildResult(sortBySender(results, dir), text);
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// RELEVANCE: default when text present and no explicit sort given
|
// RELEVANCE: default when text present and no explicit sort given
|
||||||
@@ -397,15 +567,26 @@ public class DocumentService {
|
|||||||
.sorted(Comparator.comparingInt(
|
.sorted(Comparator.comparingInt(
|
||||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||||
.toList();
|
.toList();
|
||||||
return buildResult(sorted, text);
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
Sort springSort = resolveSort(sort, dir);
|
// Fast path — push sort + paging into the DB and enrich only the returned slice.
|
||||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
|
||||||
return buildResult(results, text);
|
Page<Document> page = documentRepository.findAll(spec, pageRequest);
|
||||||
|
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
private DocumentSearchResult buildResult(List<Document> documents, String text) {
|
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
|
||||||
|
int from = Math.min((int) pageable.getOffset(), sorted.size());
|
||||||
|
int to = Math.min(from + pageable.getPageSize(), sorted.size());
|
||||||
|
return sorted.subList(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DocumentSearchResult buildResultPaged(List<Document> slice, String text, Pageable pageable, long totalElements) {
|
||||||
|
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
|
||||||
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||||
|
|
||||||
@@ -413,14 +594,12 @@ public class DocumentService {
|
|||||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
||||||
|
|
||||||
List<DocumentSearchItem> items = colorResolved.stream().map(doc -> new DocumentSearchItem(
|
return colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||||
doc,
|
doc,
|
||||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||||
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
||||||
)).toList();
|
)).toList();
|
||||||
|
|
||||||
return DocumentSearchResult.of(items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||||
|
|||||||
@@ -109,8 +109,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 +140,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());
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
|||||||
import org.apache.pdfbox.rendering.ImageType;
|
import org.apache.pdfbox.rendering.ImageType;
|
||||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.ThumbnailAspect;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -45,6 +46,9 @@ public class ThumbnailService {
|
|||||||
private static final int THUMBNAIL_WIDTH = 240;
|
private static final int THUMBNAIL_WIDTH = 240;
|
||||||
private static final float JPEG_QUALITY = 0.85f;
|
private static final float JPEG_QUALITY = 0.85f;
|
||||||
private static final int PDF_RENDER_DPI = 100;
|
private static final int PDF_RENDER_DPI = 100;
|
||||||
|
// Anything below this w/h ratio stays PORTRAIT — near-square A4 scans should
|
||||||
|
// render in the portrait tile rather than flipping to landscape at 1.01.
|
||||||
|
private static final float LANDSCAPE_THRESHOLD = 1.1f;
|
||||||
private static final String PDF_CONTENT_TYPE = "application/pdf";
|
private static final String PDF_CONTENT_TYPE = "application/pdf";
|
||||||
private static final Set<String> IMAGE_CONTENT_TYPES =
|
private static final Set<String> IMAGE_CONTENT_TYPES =
|
||||||
Set.of("image/jpeg", "image/png", "image/tiff");
|
Set.of("image/jpeg", "image/png", "image/tiff");
|
||||||
@@ -82,27 +86,46 @@ public class ThumbnailService {
|
|||||||
return Outcome.SKIPPED;
|
return Outcome.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
BufferedImage source = readSourceImage(doc, contentType);
|
SourcePreview preview = readSourcePreview(doc, contentType);
|
||||||
if (source == null) return Outcome.FAILED;
|
if (preview == null
|
||||||
|
|| preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) {
|
||||||
|
log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId());
|
||||||
|
return Outcome.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
byte[] jpeg = encodeThumbnail(source, doc.getId());
|
byte[] jpeg = encodeThumbnail(preview.image(), doc.getId());
|
||||||
if (jpeg == null) return Outcome.FAILED;
|
if (jpeg == null) return Outcome.FAILED;
|
||||||
|
|
||||||
String thumbnailKey = thumbnailKeyFor(doc.getId());
|
String thumbnailKey = thumbnailKeyFor(doc.getId());
|
||||||
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
|
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
|
||||||
|
|
||||||
return persistThumbnailMetadata(doc, thumbnailKey);
|
ThumbnailResult result = new ThumbnailResult(
|
||||||
|
thumbnailKey, aspectOf(preview.image()), preview.pageCount());
|
||||||
|
return persistThumbnailMetadata(doc, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ThumbnailAspect aspectOf(BufferedImage source) {
|
||||||
|
float ratio = (float) source.getWidth() / source.getHeight();
|
||||||
|
return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-page image + total page count for the source file. Page count is always
|
||||||
|
// 1 for image uploads; for PDFs it comes straight from PDDocument.
|
||||||
|
private record SourcePreview(BufferedImage image, int pageCount) {}
|
||||||
|
|
||||||
|
// Everything the generate pipeline has already committed to storage and
|
||||||
|
// now wants stamped onto the Document entity in a single save call.
|
||||||
|
private record ThumbnailResult(String key, ThumbnailAspect aspect, int pageCount) {}
|
||||||
|
|
||||||
private static String thumbnailKeyFor(UUID documentId) {
|
private static String thumbnailKeyFor(UUID documentId) {
|
||||||
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
|
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BufferedImage readSourceImage(Document doc, String contentType) {
|
private SourcePreview readSourcePreview(Document doc, String contentType) {
|
||||||
try {
|
try {
|
||||||
return PDF_CONTENT_TYPE.equals(contentType)
|
return PDF_CONTENT_TYPE.equals(contentType)
|
||||||
? renderPdfFirstPage(doc.getFilePath())
|
? renderPdfFirstPage(doc.getFilePath())
|
||||||
: readImage(doc.getFilePath());
|
: new SourcePreview(readImage(doc.getFilePath()), 1);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Thumbnail source read failed for doc={} reason={}",
|
log.warn("Thumbnail source read failed for doc={} reason={}",
|
||||||
doc.getId(), e.getMessage());
|
doc.getId(), e.getMessage());
|
||||||
@@ -138,10 +161,12 @@ public class ThumbnailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey) {
|
private Outcome persistThumbnailMetadata(Document doc, ThumbnailResult result) {
|
||||||
try {
|
try {
|
||||||
doc.setThumbnailKey(thumbnailKey);
|
doc.setThumbnailKey(result.key());
|
||||||
doc.setThumbnailGeneratedAt(LocalDateTime.now());
|
doc.setThumbnailGeneratedAt(LocalDateTime.now());
|
||||||
|
doc.setThumbnailAspect(result.aspect());
|
||||||
|
doc.setPageCount(result.pageCount());
|
||||||
documentRepository.save(doc);
|
documentRepository.save(doc);
|
||||||
return Outcome.SUCCESS;
|
return Outcome.SUCCESS;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -151,7 +176,7 @@ public class ThumbnailService {
|
|||||||
// overwrite it cleanly. Logging distinctly so an operator tracking
|
// overwrite it cleanly. Logging distinctly so an operator tracking
|
||||||
// backfill totals can spot the database-side issue.
|
// backfill totals can spot the database-side issue.
|
||||||
log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
|
log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
|
||||||
doc.getId(), thumbnailKey, e.getMessage());
|
doc.getId(), result.key(), e.getMessage());
|
||||||
return Outcome.FAILED;
|
return Outcome.FAILED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,11 +185,12 @@ public class ThumbnailService {
|
|||||||
return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
|
return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private BufferedImage renderPdfFirstPage(String s3Key) throws IOException {
|
private SourcePreview renderPdfFirstPage(String s3Key) throws IOException {
|
||||||
try (InputStream in = fileService.downloadFileStream(s3Key);
|
try (InputStream in = fileService.downloadFileStream(s3Key);
|
||||||
PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
|
PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
|
||||||
PDFRenderer renderer = new PDFRenderer(pdf);
|
PDFRenderer renderer = new PDFRenderer(pdf);
|
||||||
return renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB);
|
BufferedImage image = renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB);
|
||||||
|
return new SourcePreview(image, pdf.getNumberOfPages());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 @@
|
|||||||
|
-- Adds two nullable metadata columns populated by ThumbnailService when it
|
||||||
|
-- generates the JPEG preview: thumbnail_aspect (PORTRAIT | LANDSCAPE, from the
|
||||||
|
-- source image w/h ratio with threshold 1.1) and page_count (from PDDocument
|
||||||
|
-- for PDFs, 1 for image uploads). Both are null until the existing admin
|
||||||
|
-- backfill endpoint (/api/admin/generate-thumbnails) reruns the service.
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN thumbnail_aspect VARCHAR(16),
|
||||||
|
ADD COLUMN page_count INTEGER;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -69,7 +71,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -79,13 +81,13 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_withStatusParam_passesItToService() throws Exception {
|
void search_withStatusParam_passesItToService() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any());
|
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -112,12 +114,12 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseContainsTotalCount() throws Exception {
|
void search_responseContainsTotalCount() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.total").value(0))
|
.andExpect(jsonPath("$.totalElements").value(0))
|
||||||
.andExpect(jsonPath("$.items").isArray());
|
.andExpect(jsonPath("$.items").isArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +135,7 @@ class DocumentControllerTest {
|
|||||||
.build();
|
.build();
|
||||||
var matchData = new SearchMatchData(
|
var matchData = new SearchMatchData(
|
||||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||||
@@ -143,6 +145,70 @@ class DocumentControllerTest {
|
|||||||
.value("Er schrieb einen langen Brief"));
|
.value("Er schrieb einen langen Brief"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── /api/documents/search pagination ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_responseExposesPagingFields() throws Exception {
|
||||||
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.pageNumber").exists())
|
||||||
|
.andExpect(jsonPath("$.pageSize").exists())
|
||||||
|
.andExpect(jsonPath("$.totalPages").exists())
|
||||||
|
.andExpect(jsonPath("$.totalElements").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns400_whenSizeExceedsMax() throws Exception {
|
||||||
|
// Locks @Validated on the controller — removing it silently reopens the
|
||||||
|
// DoS window where a client could request all 1500 docs + enrichment.
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("size", "101"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns400_whenSizeBelowMin() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("size", "0"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns400_whenPageNegative() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("page", "-1"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns400_whenPageAboveMax() throws Exception {
|
||||||
|
// Guards against page * size overflow into negative SQL OFFSET
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("page", "200000"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_passesPageRequestToService() throws Exception {
|
||||||
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
||||||
|
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
|
||||||
|
org.springframework.data.domain.Pageable pageable = captor.getValue();
|
||||||
|
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
|
||||||
|
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -702,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;
|
||||||
@@ -183,19 +187,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 +208,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 +217,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 +229,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 +288,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 +300,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 +313,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 +363,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 +384,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 +400,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 +411,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 +422,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 +431,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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.service.TranscriptionService;
|
|||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -45,6 +46,31 @@ class DashboardServiceTest {
|
|||||||
|
|
||||||
@InjectMocks DashboardService dashboardService;
|
@InjectMocks DashboardService dashboardService;
|
||||||
|
|
||||||
|
// ─── getResume wires thumbnailUrl from Document ───────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getResume_populatesThumbnailUrl_fromDocument() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UUID docId = UUID.fromString("12345678-aaaa-bbbb-cccc-1234567890ab");
|
||||||
|
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(docId).title("Brief").originalFilename("brief.pdf")
|
||||||
|
.thumbnailKey("thumbnails/" + docId + ".jpg")
|
||||||
|
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId));
|
||||||
|
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||||
|
when(transcriptionService.listBlocks(docId)).thenReturn(List.of());
|
||||||
|
|
||||||
|
DashboardResumeDTO result = dashboardService.getResume(userId);
|
||||||
|
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.thumbnailUrl()).isEqualTo(doc.getThumbnailUrl());
|
||||||
|
assertThat(result.thumbnailUrl()).startsWith("/api/documents/" + docId + "/thumbnail?v=");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── toActorDTO (via getResume collaborators) ─────────────────────────────
|
// ─── toActorDTO (via getResume collaborators) ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||||
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.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -24,10 +25,43 @@ class DocumentSearchResultTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void of_total_equals_list_size() {
|
void of_totalElements_equals_list_size_for_unpaged_shortcut() {
|
||||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
DocumentSearchResult result = DocumentSearchResult.of(
|
||||||
|
List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
||||||
|
|
||||||
assertThat(result.total()).isEqualTo(2L);
|
assertThat(result.totalElements()).isEqualTo(2L);
|
||||||
|
assertThat(result.pageNumber()).isZero();
|
||||||
|
assertThat(result.pageSize()).isEqualTo(2);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void of_empty_shortcut_has_zero_totalPages() {
|
||||||
|
DocumentSearchResult result = DocumentSearchResult.of(List.of());
|
||||||
|
|
||||||
|
assertThat(result.totalElements()).isZero();
|
||||||
|
assertThat(result.totalPages()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
||||||
|
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
||||||
|
|
||||||
|
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.totalElements()).isEqualTo(120L);
|
||||||
|
assertThat(result.pageNumber()).isEqualTo(1);
|
||||||
|
assertThat(result.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paged_factory_totalPages_rounds_up_on_remainder() {
|
||||||
|
DocumentSearchResult result =
|
||||||
|
DocumentSearchResult.paged(List.of(), PageRequest.of(0, 7), 30L);
|
||||||
|
|
||||||
|
assertThat(result.totalPages()).isEqualTo(5); // ceil(30 / 7)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -53,9 +87,18 @@ class DocumentSearchResultTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||||
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
|
Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
|
||||||
assertThat(schema).isNotNull();
|
assertThat(schema).isNotNull();
|
||||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paging_components_are_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||||
|
for (String name : List.of("pageNumber", "pageSize", "totalPages")) {
|
||||||
|
Schema schema = DocumentSearchResult.class.getDeclaredField(name).getAnnotation(Schema.class);
|
||||||
|
assertThat(schema).as(name + " must have @Schema").isNotNull();
|
||||||
|
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DocumentTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getThumbnailUrl_returnsNull_whenThumbnailKeyNull() {
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.title("Brief")
|
||||||
|
.originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.thumbnailKey(null)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assertThat(doc.getThumbnailUrl()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getThumbnailUrl_omitsCacheBuster_whenThumbnailKeyPresentButGeneratedAtNull() {
|
||||||
|
UUID id = UUID.fromString("11111111-2222-3333-4444-555555555555");
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(id)
|
||||||
|
.title("Brief")
|
||||||
|
.originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||||
|
.thumbnailGeneratedAt(null)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assertThat(doc.getThumbnailUrl())
|
||||||
|
.isEqualTo("/api/documents/" + id + "/thumbnail");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getThumbnailUrl_includesCacheBuster_whenBothKeyAndGeneratedAtPresent() {
|
||||||
|
UUID id = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||||
|
LocalDateTime generatedAt = LocalDateTime.of(2026, 4, 23, 14, 30, 45);
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(id)
|
||||||
|
.title("Brief")
|
||||||
|
.originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||||
|
.thumbnailGeneratedAt(generatedAt)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// frontend equivalent: `?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`
|
||||||
|
// where thumbnailGeneratedAt is the ISO-8601 string Jackson serialises.
|
||||||
|
// LocalDateTime.toString() produces "2026-04-23T14:30:45"; encodeURIComponent
|
||||||
|
// turns ":" into "%3A" but leaves "T" and digits alone.
|
||||||
|
String expected = "/api/documents/" + id + "/thumbnail?v=2026-04-23T14%3A30%3A45";
|
||||||
|
assertThat(doc.getThumbnailUrl()).isEqualTo(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thumbnailUrl_isSerialisedToJson_soFrontendReceivesIt() throws Exception {
|
||||||
|
UUID id = UUID.fromString("99999999-aaaa-bbbb-cccc-111122223333");
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(id)
|
||||||
|
.title("Brief")
|
||||||
|
.originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||||
|
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||||
|
String json = mapper.writeValueAsString(doc);
|
||||||
|
|
||||||
|
// Locks the wire contract, not just the Java API: every Document JSON must carry
|
||||||
|
// `thumbnailUrl`. Protects against silent breakage if the getter gets renamed,
|
||||||
|
// hidden behind @JsonIgnore, or visibility-reduced — any of which would leave the
|
||||||
|
// frontend rendering the fallback icon on every surface.
|
||||||
|
assertThat(json).contains("\"thumbnailUrl\":\"" + doc.getThumbnailUrl() + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
|||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
|
import org.raddatz.familienarchiv.model.ThumbnailAspect;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
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;
|
||||||
@@ -65,6 +66,40 @@ class DocumentRepositoryTest {
|
|||||||
assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
|
assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── thumbnailAspect + pageCount round-trip ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void save_persistsThumbnailAspectAndPageCount() {
|
||||||
|
Document document = Document.builder()
|
||||||
|
.title("Mit Aspekt")
|
||||||
|
.originalFilename("aspect.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.thumbnailAspect(ThumbnailAspect.LANDSCAPE)
|
||||||
|
.pageCount(7)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Document saved = documentRepository.save(document);
|
||||||
|
Document found = documentRepository.findById(saved.getId()).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(found.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
|
||||||
|
assertThat(found.getPageCount()).isEqualTo(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void save_thumbnailAspectAndPageCount_defaultToNull() {
|
||||||
|
Document document = Document.builder()
|
||||||
|
.title("Ohne Aspekt")
|
||||||
|
.originalFilename("no_aspect.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Document saved = documentRepository.save(document);
|
||||||
|
Document found = documentRepository.findById(saved.getId()).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(found.getThumbnailAspect()).isNull();
|
||||||
|
assertThat(found.getPageCount()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── findByStatus ─────────────────────────────────────────────────────────
|
// ─── findByStatus ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -302,6 +302,51 @@ class MigrationIntegrationTest {
|
|||||||
).isInstanceOf(DataIntegrityViolationException.class);
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── V53: add thumbnail_aspect + page_count columns to documents ─────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v53_thumbnailAspectColumn_existsAndIsNullable() {
|
||||||
|
UUID docId = createDocument();
|
||||||
|
|
||||||
|
// Column must exist and accept NULL (freshly-created doc has no thumbnail yet)
|
||||||
|
String aspect = jdbc.queryForObject(
|
||||||
|
"SELECT thumbnail_aspect FROM documents WHERE id = ?", String.class, docId);
|
||||||
|
assertThat(aspect).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v53_pageCountColumn_existsAndIsNullable() {
|
||||||
|
UUID docId = createDocument();
|
||||||
|
|
||||||
|
Integer pageCount = jdbc.queryForObject(
|
||||||
|
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
|
||||||
|
assertThat(pageCount).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v53_thumbnailAspectColumn_acceptsPortraitAndLandscape() {
|
||||||
|
UUID docId = createDocument();
|
||||||
|
|
||||||
|
int portraitRows = jdbc.update(
|
||||||
|
"UPDATE documents SET thumbnail_aspect = 'PORTRAIT' WHERE id = ?", docId);
|
||||||
|
assertThat(portraitRows).isEqualTo(1);
|
||||||
|
|
||||||
|
int landscapeRows = jdbc.update(
|
||||||
|
"UPDATE documents SET thumbnail_aspect = 'LANDSCAPE' WHERE id = ?", docId);
|
||||||
|
assertThat(landscapeRows).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v53_pageCountColumn_storesInteger() {
|
||||||
|
UUID docId = createDocument();
|
||||||
|
|
||||||
|
jdbc.update("UPDATE documents SET page_count = 4 WHERE id = ?", docId);
|
||||||
|
|
||||||
|
Integer stored = jdbc.queryForObject(
|
||||||
|
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
|
||||||
|
assertThat(stored).isEqualTo(4);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── V51: backfill annotation_id on block comments and notifications ─────
|
// ─── V51: backfill annotation_id on block comments and notifications ─────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.test.annotation.DirtiesContext;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
|
||||||
|
* Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120
|
||||||
|
* UPLOADED documents and asserts the slice/total/totalPages arithmetic holds
|
||||||
|
* against the actual JPA query.
|
||||||
|
*
|
||||||
|
* <p>Closes the integration-coverage gap Sara flagged on PR #316.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
|
class DocumentSearchPagedIntegrationTest {
|
||||||
|
|
||||||
|
private static final int FIXTURE_SIZE = 120;
|
||||||
|
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
@Autowired DocumentService documentService;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
// Deterministic date spread so DATE-DESC order is predictable:
|
||||||
|
// document #0 has the oldest date, document #119 has the newest.
|
||||||
|
for (int i = 0; i < FIXTURE_SIZE; i++) {
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.title("Dok-" + String.format("%03d", i))
|
||||||
|
.originalFilename("dok-" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.documentDate(LocalDate.of(1900, 1, 1).plusDays(i))
|
||||||
|
.build();
|
||||||
|
documentRepository.save(doc);
|
||||||
|
}
|
||||||
|
assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(0, 50));
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(50);
|
||||||
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
assertThat(result.pageNumber()).isZero();
|
||||||
|
assertThat(result.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_lastPartialPage_returnsRemainingItems() {
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(2, 50));
|
||||||
|
|
||||||
|
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
||||||
|
assertThat(result.items()).hasSize(20);
|
||||||
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
assertThat(result.pageNumber()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(99, 50));
|
||||||
|
|
||||||
|
assertThat(result.items()).isEmpty();
|
||||||
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() {
|
||||||
|
// SENDER sort path fetches all + sorts + slices in-memory (see scaling
|
||||||
|
// comment in DocumentService). Proves that the in-memory slice path
|
||||||
|
// returns the correct total from a real repository fetch.
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.SENDER, "asc", null,
|
||||||
|
PageRequest.of(1, 50));
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(50);
|
||||||
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
assertThat(result.pageNumber()).isEqualTo(1);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_differentPagesReturnDisjointSlices() {
|
||||||
|
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(0, 50));
|
||||||
|
DocumentSearchResult page1 = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(1, 50));
|
||||||
|
|
||||||
|
// No document id should appear on both pages — slicing must be exclusive.
|
||||||
|
var idsOnPage0 = page0.items().stream()
|
||||||
|
.map(item -> item.document().getId())
|
||||||
|
.toList();
|
||||||
|
var idsOnPage1 = page1.items().stream()
|
||||||
|
.map(item -> item.document().getId())
|
||||||
|
.toList();
|
||||||
|
for (UUID id : idsOnPage0) {
|
||||||
|
assertThat(idsOnPage1).doesNotContain(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ import org.raddatz.familienarchiv.dto.DocumentSort;
|
|||||||
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.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -25,6 +26,8 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceSortTest {
|
class DocumentServiceSortTest {
|
||||||
|
|
||||||
|
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@@ -51,12 +54,12 @@ class DocumentServiceSortTest {
|
|||||||
|
|
||||||
// FTS returns id1 first (higher rank), id2 second
|
// FTS returns id1 first (higher rank), id2 second
|
||||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||||
// findAll(spec, sort) — the correct date path — returns date-DESC order
|
// findAll(spec, pageable) — the correct date path — returns date-DESC order
|
||||||
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of(newer, older));
|
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
|
||||||
|
|
||||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
@@ -78,7 +81,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||||
|
|
||||||
// Expect: rank order restored (id1 first)
|
// Expect: rank order restored (id1 first)
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
@@ -97,7 +100,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1));
|
.thenReturn(List.of(doc2, doc1));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, null, null, null);
|
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -24,6 +25,7 @@ import org.raddatz.familienarchiv.model.Tag;
|
|||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
@@ -46,6 +48,12 @@ import static org.mockito.Mockito.*;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceTest {
|
class DocumentServiceTest {
|
||||||
|
|
||||||
|
// Used by tests that don't care about paging. 10 000 is chosen large enough
|
||||||
|
// to hold any fixture in this file but small enough that totalPages math
|
||||||
|
// stays in int range. Swap to `PageRequest.of(0, 10_000)` elsewhere is a
|
||||||
|
// red flag — use this constant.
|
||||||
|
private static final Pageable UNPAGED = PageRequest.of(0, 10_000);
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@@ -113,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
|
||||||
@@ -1323,26 +1348,124 @@ class DocumentServiceTest {
|
|||||||
assertThat(result).isNull();
|
assertThat(result).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── searchDocuments — pagination ────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() {
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
|
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||||
|
|
||||||
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
|
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_fastPath_propagatesPageableToDatabase() {
|
||||||
|
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
|
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(3, 25));
|
||||||
|
|
||||||
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||||
|
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
||||||
|
assertThat(captor.getValue().getPageSize()).isEqualTo(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_fastPath_returnsPageableTotalsOnResult() {
|
||||||
|
// The service MUST report the full match count from Page.getTotalElements(),
|
||||||
|
// not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong.
|
||||||
|
Document d = Document.builder().id(UUID.randomUUID()).title("T").build();
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(0, 50));
|
||||||
|
|
||||||
|
assertThat(result.totalElements()).isEqualTo(120L);
|
||||||
|
assertThat(result.pageNumber()).isZero();
|
||||||
|
assertThat(result.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50)
|
||||||
|
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
||||||
|
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
|
||||||
|
// back with totalElements = 120.
|
||||||
|
List<Document> all = new java.util.ArrayList<>();
|
||||||
|
for (int i = 0; i < 120; i++) {
|
||||||
|
Person p = Person.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.firstName("F" + i)
|
||||||
|
.lastName(String.format("L%03d", i))
|
||||||
|
.build();
|
||||||
|
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||||
|
}
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
|
.thenReturn(all);
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||||
|
|
||||||
|
assertThat(result.totalElements()).isEqualTo(120L);
|
||||||
|
assertThat(result.pageNumber()).isEqualTo(1);
|
||||||
|
assertThat(result.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3);
|
||||||
|
assertThat(result.items()).hasSize(50);
|
||||||
|
// Page 1 (offset 50) under ascending sender sort should start at L050
|
||||||
|
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() {
|
||||||
|
// Guards the JPA edge case where page * size > totalElements.
|
||||||
|
// Must not throw, must return empty content + correct totalElements.
|
||||||
|
List<Document> all = new java.util.ArrayList<>();
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build();
|
||||||
|
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||||
|
}
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
|
.thenReturn(all);
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||||
|
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(10, 50));
|
||||||
|
|
||||||
|
assertThat(result.items()).isEmpty();
|
||||||
|
assertThat(result.totalElements()).isEqualTo(30L);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── searchDocuments — status filter ─────────────────────────────────────
|
// ─── searchDocuments — status filter ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_passesStatusSpecificationToRepository() {
|
void searchDocuments_passesStatusSpecificationToRepository() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null);
|
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null);
|
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── getRecentActivity ────────────────────────────────────────────────────
|
// ─── getRecentActivity ────────────────────────────────────────────────────
|
||||||
@@ -1418,7 +1541,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(withSender, noSender));
|
.thenReturn(List.of(withSender, noSender));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||||
@@ -1438,7 +1561,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(noReceivers, withReceiver));
|
.thenReturn(List.of(noReceivers, withReceiver));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null);
|
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||||
.containsExactly("Has Receiver", "No Receivers");
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
@@ -1460,7 +1583,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(docNullName, docSmith));
|
.thenReturn(List.of(docNullName, docSmith));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||||
@@ -1482,7 +1605,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
assertThat(result.items()).hasSize(1);
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
@@ -1492,11 +1615,12 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, null, null, null);
|
null, null, null, null, null, null, null, null, null, null, null,
|
||||||
|
UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
assertThat(result.items()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -1515,7 +1639,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||||
|
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||||
@@ -1707,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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,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 +182,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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
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.ThumbnailAspect;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import software.amazon.awssdk.core.sync.RequestBody;
|
import software.amazon.awssdk.core.sync.RequestBody;
|
||||||
@@ -167,6 +168,116 @@ class ThumbnailServiceTest {
|
|||||||
verify(documentRepository, never()).save(any());
|
verify(documentRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_persistsPageCount_ofOne_forSingleImageUpload() throws IOException {
|
||||||
|
// Image uploads are always a single page from the user's perspective.
|
||||||
|
Document doc = makeDoc("image/png", "documents/scan.png");
|
||||||
|
when(fileService.downloadFileStream(anyString()))
|
||||||
|
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
|
||||||
|
|
||||||
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||||
|
assertThat(doc.getPageCount()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_persistsPageCount_fromPdfDocument() throws IOException {
|
||||||
|
Document doc = makeDoc("application/pdf", "documents/multi.pdf");
|
||||||
|
when(fileService.downloadFileStream(anyString()))
|
||||||
|
.thenReturn(new ByteArrayInputStream(createSamplePdf(3)));
|
||||||
|
|
||||||
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||||
|
assertThat(doc.getPageCount()).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException {
|
||||||
|
// 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT.
|
||||||
|
Document doc = makeDoc("image/png", "documents/portrait.png");
|
||||||
|
when(fileService.downloadFileStream(anyString()))
|
||||||
|
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
|
||||||
|
|
||||||
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||||
|
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_persistsLandscapeAspect_whenWidthIsWellAboveHeight() throws IOException {
|
||||||
|
// 800x400 → ratio 2.0 → clearly above 1.1 → LANDSCAPE.
|
||||||
|
Document doc = makeDoc("image/jpeg", "documents/wide.jpg");
|
||||||
|
when(fileService.downloadFileStream(anyString()))
|
||||||
|
.thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));
|
||||||
|
|
||||||
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||||
|
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_persistsPortraitAspect_whenSquareImage_belowLandscapeThreshold() throws IOException {
|
||||||
|
// 500x500 → ratio 1.0 → below 1.1 threshold → PORTRAIT (A4 scans often
|
||||||
|
// come in at near-square and we want them to live in the portrait tile).
|
||||||
|
Document doc = makeDoc("image/png", "documents/square.png");
|
||||||
|
when(fileService.downloadFileStream(anyString()))
|
||||||
|
.thenReturn(new ByteArrayInputStream(createSamplePng(500, 500)));
|
||||||
|
|
||||||
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||||
|
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_persistsPortraitAspect_justUnderLandscapeThreshold() throws IOException {
|
||||||
|
// 1099x1000 → ratio 1.099 → just under 1.1 threshold → PORTRAIT.
|
||||||
|
Document doc = makeDoc("image/png", "documents/near_threshold.png");
|
||||||
|
when(fileService.downloadFileStream(anyString()))
|
||||||
|
.thenReturn(new ByteArrayInputStream(createSamplePng(1099, 1000)));
|
||||||
|
|
||||||
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
||||||
|
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException {
|
||||||
|
// Truncated JPEG header — ImageIO returns null rather than throwing.
|
||||||
|
// Without the corrupt-image guard this would later NPE inside the aspect /
|
||||||
|
// dimension computation in scaleToWidth.
|
||||||
|
Document doc = makeDoc("image/jpeg", "documents/corrupt.jpg");
|
||||||
|
byte[] truncated = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0};
|
||||||
|
when(fileService.downloadFileStream(anyString()))
|
||||||
|
.thenReturn(new ByteArrayInputStream(truncated));
|
||||||
|
|
||||||
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||||
|
verifyNoInteractions(s3Client);
|
||||||
|
verify(documentRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generate_returnsFailed_whenPdfBytesAreCorrupt() throws IOException {
|
||||||
|
// "PDF" header but no body — PDFBox throws IOException while loading.
|
||||||
|
Document doc = makeDoc("application/pdf", "documents/corrupt.pdf");
|
||||||
|
byte[] fakePdf = "%PDF-1.4\n".getBytes();
|
||||||
|
when(fileService.downloadFileStream(anyString()))
|
||||||
|
.thenReturn(new ByteArrayInputStream(fakePdf));
|
||||||
|
|
||||||
|
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
||||||
|
verifyNoInteractions(s3Client);
|
||||||
|
verify(documentRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException {
|
void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException {
|
||||||
// Covers the "orphan thumbnail" edge case: S3 upload succeeded but the
|
// Covers the "orphan thumbnail" edge case: S3 upload succeeded but the
|
||||||
@@ -202,15 +313,21 @@ class ThumbnailServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] createSamplePdf() throws IOException {
|
private static byte[] createSamplePdf() throws IOException {
|
||||||
|
return createSamplePdf(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] createSamplePdf(int pageCount) throws IOException {
|
||||||
try (PDDocument doc = new PDDocument()) {
|
try (PDDocument doc = new PDDocument()) {
|
||||||
PDPage page = new PDPage(PDRectangle.A4);
|
for (int i = 0; i < pageCount; i++) {
|
||||||
doc.addPage(page);
|
PDPage page = new PDPage(PDRectangle.A4);
|
||||||
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
|
doc.addPage(page);
|
||||||
content.beginText();
|
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
|
||||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
|
content.beginText();
|
||||||
content.newLineAtOffset(100, 700);
|
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
|
||||||
content.showText("Lieber Hans,");
|
content.newLineAtOffset(100, 700);
|
||||||
content.endText();
|
content.showText("Lieber Hans,");
|
||||||
|
content.endText();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||||
doc.save(bos);
|
doc.save(bos);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
52
docs/adr/005-thumbnail-aspect-and-page-count.md
Normal file
52
docs/adr/005-thumbnail-aspect-and-page-count.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# ADR-005: thumbnailAspect + pageCount alongside the thumbnail
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Issue #305 rebalances the /briefwechsel correspondence list into PDF-thumbnail rows. Two pieces of metadata are needed at row-render time:
|
||||||
|
|
||||||
|
- **Aspect ratio** — postcards are landscape (7:5), letters are portrait (5:7). Forcing landscape scans into a portrait tile crops away the signature; forcing portrait scans into a landscape tile wastes horizontal real estate.
|
||||||
|
- **Page count** — multi-page letters should show a "N" badge on their thumbnail so the reader can tell a single-page note from a seven-page letter without clicking in.
|
||||||
|
|
||||||
|
Both values are cheap to derive at the point the thumbnail is generated (the source image is already decoded; the PDF is already loaded) and impossible to derive cheaply later (requires re-reading the S3 object).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Persist both values as columns on `documents` and populate them inside `ThumbnailService.generate()` — the same code path that writes the JPEG to S3 and stamps `thumbnail_generated_at`.
|
||||||
|
|
||||||
|
- `thumbnail_aspect VARCHAR(16)` mapped to a Java enum `ThumbnailAspect` with two values: `PORTRAIT`, `LANDSCAPE`.
|
||||||
|
- `page_count INTEGER` — `PDDocument.getNumberOfPages()` for PDFs, `1` for image uploads.
|
||||||
|
- Aspect threshold is `source.width / source.height > 1.1` → `LANDSCAPE`; everything else (including near-square A4 scans at ratio ≈ 1.0) stays `PORTRAIT`. The 1.1 margin keeps borderline scans from flipping across the threshold on a rounding error.
|
||||||
|
- Both columns are nullable and remain `null` for historical documents until the existing `/api/admin/generate-thumbnails` backfill rerun populates them.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
| Alternative | Why rejected |
|
||||||
|
|---|---|
|
||||||
|
| Derive aspect client-side after image load | First-paint would have all tiles in portrait, then reshuffle into landscape when the JPEG decodes — a visible jank on slow networks. The backend already has the dimensions; client-side recomputation is a waste. |
|
||||||
|
| Store full `width` / `height` columns | Not needed anywhere — consumers want the categorical answer. If a future feature needs exact dimensions, they can be added later without migrating existing rows. |
|
||||||
|
| A separate `thumbnail_metadata` table | Two scalar nullable columns aren't worth a join. See ADR-004 — thumbnails are modeled as a cross-cutting aspect of `Document`, not a sub-domain. |
|
||||||
|
| Derive page count from the existing PDF at render time on the frontend | Duplicates work already done on the backend and requires a separate byte-range fetch of the PDF header. Frontend already gets `pageCount` "for free" via the Document response. |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Easier:**
|
||||||
|
- `ConversationThumbnail.svelte` picks the tile dimensions from `thumbnailAspect` directly — no async measurement, no layout shift.
|
||||||
|
- `ThumbnailRow` reads `pageCount` synchronously for the badge. Multi-page letters are distinguishable at first paint.
|
||||||
|
- Backfill runs the same migration path for every old document — re-executing generates the aspect + pageCount columns along with the JPEG, so operators don't have a second admin button to click.
|
||||||
|
|
||||||
|
**Harder:**
|
||||||
|
- Both columns are `null` for every document until the backfill runs on a given instance. Frontend components guard with `?? 'PORTRAIT'` / `?? 1` so the UI stays sensible during the rollout window. The backfill is idempotent and cheap (reuses existing S3 object), so re-running it is the simplest recovery path.
|
||||||
|
- The aspect threshold is a single constant in Java. A future need to tune per-type (e.g. postcards vs photos) means a code change, not a configuration change — acceptable for a single-operator archive.
|
||||||
|
|
||||||
|
### Ordering inside `ThumbnailService.generate()`
|
||||||
|
|
||||||
|
Aspect computation happens AFTER the JPEG upload succeeds but BEFORE the entity save — if the save throws, the columns rewind with it. Page count is captured while the `PDDocument` is still open; the `SourcePreview` record carries both the rendered first-page image and the page count back to the top of the pipeline so the PDF isn't reopened later.
|
||||||
|
|
||||||
|
## Future Direction
|
||||||
|
|
||||||
|
- If a postcard-specific "photo" chip is ever reintroduced, reuse `thumbnailAspect === 'LANDSCAPE' && pageCount === 1` rather than adding a new `kind` column.
|
||||||
|
- If multi-size thumbnails are introduced (per ADR-004's future note), the aspect + pageCount are per-document and do not need to be duplicated per size.
|
||||||
996
docs/specs/bulk-upload-concepts.html
Normal file
996
docs/specs/bulk-upload-concepts.html
Normal file
@@ -0,0 +1,996 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Bulk Upload — 3 Concept Designs · Familienarchiv</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;1,300&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
/* ── Reset ── */
|
||||||
|
*,*::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 #002850;margin-bottom:60px}
|
||||||
|
.mh .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
|
||||||
|
.mh h1{font-size:28px;font-weight:900;color:#002850;letter-spacing:-.4px;margin-top:6px}
|
||||||
|
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:10px}
|
||||||
|
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:14px}
|
||||||
|
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
|
||||||
|
.tag{background:#002850;color:#A6DAD8;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||||
|
.tag.amber{background:#7c4a00;color:#fde68a}
|
||||||
|
.tag.green{background:#1e5e34;color:#d1fae5}
|
||||||
|
.tag.gray{background:#4b5563;color:#e5e7eb}
|
||||||
|
.tag.mint{background:#A6DAD8;color:#002850}
|
||||||
|
|
||||||
|
/* ── Goals card ── */
|
||||||
|
.goals{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:22px 26px;margin:0 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||||
|
.goals h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:14px}
|
||||||
|
.goals ul{list-style:none;display:grid;grid-template-columns:1fr 1fr;gap:10px 28px}
|
||||||
|
.goals li{font-size:12.5px;color:#333;padding-left:20px;position:relative;line-height:1.55}
|
||||||
|
.goals li::before{content:"→";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||||
|
|
||||||
|
/* ── Concept section ── */
|
||||||
|
.concept{margin-bottom:88px;padding-bottom:88px;border-bottom:2px dashed #C8C4BE}
|
||||||
|
.concept:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||||
|
.concept-header{display:flex;align-items:flex-start;gap:24px;margin-bottom:36px}
|
||||||
|
.concept-num{font-size:84px;font-weight:900;color:#E0DDD6;line-height:1;flex-shrink:0;width:96px}
|
||||||
|
.concept-label{font-size:8.5px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#A6DAD8;margin-bottom:5px}
|
||||||
|
.concept-title{font-family:'Merriweather',Georgia,serif;font-size:24px;font-weight:700;color:#002850;margin-bottom:10px}
|
||||||
|
.concept-desc{font-size:13.5px;color:#555;max-width:740px;line-height:1.75}
|
||||||
|
.concept-best{margin-top:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||||
|
.best-label{background:#A6DAD8;color:#002850;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
|
||||||
|
.best-text{font-size:12px;font-weight:600;color:#444}
|
||||||
|
.concept-tradeoff{margin-top:8px;font-size:12px;color:#888;font-style:italic;max-width:680px;line-height:1.7}
|
||||||
|
|
||||||
|
/* ── Browser chrome ── */
|
||||||
|
.screen{max-width:980px;margin:0 auto}
|
||||||
|
.screen.narrow{max-width:400px}
|
||||||
|
.chrome{background:#F5F4EE;border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
|
||||||
|
.chrome-bar{height:22px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 9px;gap:5px;flex-shrink:0}
|
||||||
|
.chrome-dot{width:7px;height:7px;border-radius:50%;background:#BDB8B1}
|
||||||
|
.chrome-url{flex:1;height:10px;background:#CCC8C2;border-radius:5px;margin-left:8px}
|
||||||
|
.viewport-hint{font-size:7.5px;font-weight:800;color:#A6DAD8;letter-spacing:1px;text-transform:uppercase;padding:4px 9px;background:#002850;border-radius:2px;margin-left:8px}
|
||||||
|
|
||||||
|
/* ── App nav ── */
|
||||||
|
.app-nav{height:32px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:12px;flex-shrink:0}
|
||||||
|
.app-logo{font-family:'Merriweather',Georgia,serif;font-size:8px;font-weight:700;color:#fff;border-bottom:2px solid #A6DAD8;padding-bottom:1px}
|
||||||
|
.app-link{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.45);white-space:nowrap}
|
||||||
|
.app-link.on{color:rgba(255,255,255,.9)}
|
||||||
|
.app-nav-r{margin-left:auto;display:flex;gap:8px;align-items:center}
|
||||||
|
.app-avatar{width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.5)}
|
||||||
|
|
||||||
|
/* ── Common form element styles ── */
|
||||||
|
.f-label{font-size:6.5px;font-weight:700;color:#666;letter-spacing:.2px;text-transform:uppercase}
|
||||||
|
.f-req{color:#C0392B}
|
||||||
|
.f-input{height:20px;border:1px solid #D4D0CA;border-radius:2px;background:#fff;font-size:7.5px;padding:0 7px;color:#333;display:flex;align-items:center}
|
||||||
|
.f-input.focus{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.12)}
|
||||||
|
.f-input.filled{color:#002850;font-weight:600;background:#FAFBFF}
|
||||||
|
.f-input.suggested{border-color:#A6DAD8;background:#F0FAFA;color:#005858;font-weight:600}
|
||||||
|
.f-input.empty{color:#BBB;font-style:italic}
|
||||||
|
.f-input.tall{height:28px}
|
||||||
|
|
||||||
|
.f-tags{display:flex;gap:3px;flex-wrap:wrap;min-height:20px;border:1px solid #D4D0CA;border-radius:2px;padding:2px 4px;background:#fff;align-items:center}
|
||||||
|
.f-chip{background:#002850;color:#A6DAD8;border-radius:2px;font-size:6px;font-weight:700;padding:1px 4px 1px 5px;display:flex;align-items:center;gap:2px}
|
||||||
|
.f-chip-rm{color:rgba(166,218,216,.5);font-weight:400}
|
||||||
|
|
||||||
|
/* ── Action bar ── */
|
||||||
|
.action-bar{height:46px;background:#F5F4EE;border-top:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px;flex-shrink:0}
|
||||||
|
.btn-skip{font-size:7px;font-weight:700;color:#AAA;letter-spacing:.2px;cursor:pointer}
|
||||||
|
.btn-spacer{flex:1}
|
||||||
|
.btn-outline{height:24px;padding:0 12px;border:1px solid #C0BDB6;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#777;display:flex;align-items:center;cursor:pointer;background:#fff}
|
||||||
|
.btn-primary{height:24px;padding:0 12px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;background:#002850;color:#fff;display:flex;align-items:center;cursor:pointer;gap:4px}
|
||||||
|
.btn-primary.green{background:#1A7040}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────── */
|
||||||
|
/* ── CONCEPT A — Stack (mobile-first) ── */
|
||||||
|
/* ─────────────────────────────────────── */
|
||||||
|
.ca-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:8px}
|
||||||
|
.ca-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||||
|
.ca-title{flex:1;text-align:center;font-family:'Merriweather',Georgia,serif;font-size:9px;color:#002850;font-weight:600}
|
||||||
|
.ca-count{font-size:7px;font-weight:700;color:#002850;background:#A6DAD8;padding:2px 6px;border-radius:10px;letter-spacing:.3px}
|
||||||
|
|
||||||
|
.ca-body{background:#ECEAE4;padding:14px 12px;overflow-y:auto}
|
||||||
|
|
||||||
|
.ca-drop{background:#fff;border:2px dashed #A6DAD8;border-radius:4px;padding:14px;text-align:center;margin-bottom:14px}
|
||||||
|
.ca-drop-icon{font-size:18px;color:#A6DAD8;margin-bottom:4px}
|
||||||
|
.ca-drop-title{font-size:8.5px;font-weight:700;color:#002850;margin-bottom:2px}
|
||||||
|
.ca-drop-sub{font-size:6.5px;color:#999}
|
||||||
|
|
||||||
|
.ca-shared-card{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,.03)}
|
||||||
|
.ca-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:11px}
|
||||||
|
.ca-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||||
|
.ca-shared-title{font-family:'Merriweather',Georgia,serif;font-size:9.5px;color:#002850;font-weight:700}
|
||||||
|
.ca-shared-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 10px}
|
||||||
|
.ca-shared-grid .full{grid-column:1/-1}
|
||||||
|
.ca-shared-field{display:flex;flex-direction:column;gap:3px}
|
||||||
|
|
||||||
|
.ca-files-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 2px}
|
||||||
|
.ca-files-title{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6}
|
||||||
|
.ca-files-add{font-size:7px;font-weight:700;color:#002850;display:flex;align-items:center;gap:3px}
|
||||||
|
|
||||||
|
.ca-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:9px 10px;margin-bottom:7px;display:flex;align-items:center;gap:10px}
|
||||||
|
.ca-file.active{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.08)}
|
||||||
|
.ca-thumb{width:28px;height:36px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;flex-shrink:0;display:flex;flex-direction:column;padding:3px;gap:1px}
|
||||||
|
.ca-thumb .tl{height:2px;background:#C4BDB0;opacity:.6;border-radius:1px}
|
||||||
|
.ca-thumb .tl.s{width:60%;opacity:.35}
|
||||||
|
.ca-thumb .tl.m{width:82%}
|
||||||
|
.ca-file-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
||||||
|
.ca-file-title{font-size:8px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.ca-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
|
||||||
|
.ca-file-meta{font-size:6.5px;color:#AAA}
|
||||||
|
.ca-file-rm{font-size:10px;color:#B0ADA6;padding:0 4px;cursor:pointer}
|
||||||
|
|
||||||
|
/* ───────────────────────────────────────────── */
|
||||||
|
/* ── CONCEPT B — Split-panel + file switcher ── */
|
||||||
|
/* ───────────────────────────────────────────── */
|
||||||
|
.cb-top-bar{height:38px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:10px}
|
||||||
|
.cb-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||||
|
.cb-title{font-family:'Merriweather',Georgia,serif;font-size:9px;font-weight:700;color:#002850}
|
||||||
|
.cb-count{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:10px;font-size:7px;font-weight:800;letter-spacing:.3px}
|
||||||
|
.cb-discard{margin-left:auto;font-size:7px;font-weight:700;color:#C0392B;letter-spacing:.2px}
|
||||||
|
|
||||||
|
.cb-split{display:flex;min-height:440px}
|
||||||
|
.cb-pdf{flex:55;background:#5E5C59;display:flex;flex-direction:column;border-right:1px solid #3A3836}
|
||||||
|
.cb-pdf-toolbar{height:28px;background:#3A3836;display:flex;align-items:center;padding:0 10px;gap:8px}
|
||||||
|
.cb-pdf-btn{width:16px;height:16px;border-radius:2px;background:rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center;font-size:7px;color:rgba(255,255,255,.6)}
|
||||||
|
.cb-pdf-page{font-size:6.5px;color:rgba(255,255,255,.4);margin-left:auto;font-weight:700;letter-spacing:.5px}
|
||||||
|
.cb-pdf-view{flex:1;display:flex;justify-content:center;padding:14px;overflow:hidden}
|
||||||
|
.cb-paper{background:#FFFEF8;box-shadow:0 2px 10px rgba(0,0,0,.3);border-radius:1px;padding:14px 16px;display:flex;flex-direction:column;gap:0;width:180px;flex-shrink:0}
|
||||||
|
.pl{height:4px;background:#C4BDB0;border-radius:1px;opacity:.55;margin-bottom:3px}
|
||||||
|
.pl.h{height:6px;opacity:.75;margin-bottom:5px}
|
||||||
|
.pl.s{width:55%;opacity:.3}
|
||||||
|
.pl.m{width:80%}
|
||||||
|
.pl.sp{height:7px;background:transparent}
|
||||||
|
.cb-filebar{background:#434140;border-top:1px solid #3A3836;display:flex;align-items:center;padding:0 8px;gap:3px;height:36px;flex-shrink:0}
|
||||||
|
.cb-fb-arrow{width:18px;height:22px;border-radius:2px;background:rgba(255,255,255,.08);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,.6)}
|
||||||
|
.cb-fb-track{flex:1;display:flex;gap:3px;padding:0 3px;overflow:hidden}
|
||||||
|
.cb-fb-item{padding:3px 6px;border-radius:2px;font-size:6px;font-weight:700;color:rgba(255,255,255,.55);background:rgba(255,255,255,.06);display:flex;align-items:center;gap:4px;white-space:nowrap}
|
||||||
|
.cb-fb-item.on{background:#A6DAD8;color:#002850}
|
||||||
|
.cb-fb-num{background:rgba(0,0,0,.15);border-radius:2px;padding:0 3px;font-size:5.5px;font-weight:800}
|
||||||
|
.cb-fb-item.on .cb-fb-num{background:rgba(0,40,80,.25);color:#002850}
|
||||||
|
|
||||||
|
.cb-form{flex:45;background:#fff;display:flex;flex-direction:column}
|
||||||
|
.cb-form-scroll{flex:1;overflow-y:auto;padding:14px}
|
||||||
|
|
||||||
|
.cb-only-card{background:#F0FAFA;border:1px solid #A6DAD8;border-radius:3px;padding:10px 12px;margin-bottom:12px}
|
||||||
|
.cb-only-head{display:flex;align-items:center;gap:6px;margin-bottom:7px}
|
||||||
|
.cb-only-badge{background:#005858;color:#A6DAD8;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||||
|
.cb-only-subtitle{font-size:6.5px;color:#005858;font-weight:600;letter-spacing:.3px}
|
||||||
|
|
||||||
|
.cb-shared-card{background:#F9F8F5;border:1px solid #E4E2D7;border-radius:3px;padding:10px 12px;margin-bottom:10px}
|
||||||
|
.cb-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:9px}
|
||||||
|
.cb-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||||
|
.cb-shared-subtitle{font-size:6.5px;color:#002850;font-weight:600}
|
||||||
|
.cb-row{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:7px}
|
||||||
|
.cb-row.full{grid-template-columns:1fr}
|
||||||
|
.cb-field{display:flex;flex-direction:column;gap:3px}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────── */
|
||||||
|
/* ── CONCEPT C — Progressive accordion ── */
|
||||||
|
/* ─────────────────────────────────────── */
|
||||||
|
.cc-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px}
|
||||||
|
|
||||||
|
.cc-body{background:#ECEAE4;padding:14px;display:flex;flex-direction:column;gap:11px;max-height:540px;overflow-y:auto}
|
||||||
|
|
||||||
|
.cc-shared{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;box-shadow:0 1px 2px rgba(0,0,0,.03);position:sticky;top:0;z-index:2}
|
||||||
|
.cc-shared-head{display:flex;align-items:center;gap:7px;margin-bottom:11px}
|
||||||
|
.cc-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||||
|
.cc-shared-title{font-family:'Merriweather',Georgia,serif;font-size:10px;color:#002850;font-weight:700}
|
||||||
|
.cc-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px 10px}
|
||||||
|
.cc-grid .span2{grid-column:span 2}
|
||||||
|
|
||||||
|
.cc-files-label{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6;padding:0 2px;margin-top:6px}
|
||||||
|
|
||||||
|
.cc-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;overflow:hidden}
|
||||||
|
.cc-file.open{border-color:#002850;box-shadow:0 2px 6px rgba(0,40,80,.08)}
|
||||||
|
.cc-file-head{display:flex;align-items:center;gap:10px;padding:9px 12px;cursor:pointer}
|
||||||
|
.cc-file-head.open{border-bottom:1px solid #E4E2D7;background:#F9F8F5}
|
||||||
|
.cc-caret{font-size:9px;color:#A6DAD8;width:10px}
|
||||||
|
.cc-file-thumb{width:22px;height:28px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;padding:2px;display:flex;flex-direction:column;gap:1px;flex-shrink:0}
|
||||||
|
.cc-file-thumb .tl{height:2px;background:#C4BDB0;opacity:.55;border-radius:1px}
|
||||||
|
.cc-file-body{flex:1;min-width:0}
|
||||||
|
.cc-file-titlerow{display:flex;align-items:center;gap:7px}
|
||||||
|
.cc-file-title{font-size:8.5px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.cc-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
|
||||||
|
.cc-file-meta{font-size:6.5px;color:#AAA;margin-top:2px}
|
||||||
|
.cc-file-rm{font-size:11px;color:#B0ADA6;padding:0 4px}
|
||||||
|
|
||||||
|
.cc-file-open{display:flex;background:#F5F4EE}
|
||||||
|
.cc-preview{flex:45;background:#5E5C59;padding:12px;display:flex;justify-content:center}
|
||||||
|
.cc-preview-paper{background:#FFFEF8;border-radius:1px;padding:8px 10px;width:110px;flex-shrink:0;display:flex;flex-direction:column;box-shadow:0 2px 6px rgba(0,0,0,.25)}
|
||||||
|
.cc-file-form{flex:55;padding:12px 14px;background:#fff;display:flex;flex-direction:column;gap:7px}
|
||||||
|
|
||||||
|
/* ─────────── Decision matrix ─────────── */
|
||||||
|
.decision{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;margin:88px 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||||
|
.decision h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:6px}
|
||||||
|
.decision p.lead{font-size:13.5px;color:#555;line-height:1.7;margin-bottom:22px;max-width:820px}
|
||||||
|
.dm{width:100%;border-collapse:collapse;margin-top:12px;font-size:12px}
|
||||||
|
.dm th{text-align:left;font-size:9.5px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#002850;padding:9px 12px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
|
||||||
|
.dm td{padding:13px 12px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.6}
|
||||||
|
.dm td:first-child{font-weight:700;color:#002850;width:18%;white-space:nowrap}
|
||||||
|
.dm td.score{font-size:15px;text-align:center;width:12%}
|
||||||
|
.dm td.ok{color:#1A7040}
|
||||||
|
.dm td.mid{color:#A07100}
|
||||||
|
.dm td.bad{color:#C0392B}
|
||||||
|
|
||||||
|
/* ─────────── Recommendation ─────────── */
|
||||||
|
.reco{background:#002850;color:#fff;border-radius:6px;padding:36px 40px;margin:48px 0 64px;box-shadow:0 4px 20px rgba(0,40,80,.15)}
|
||||||
|
.reco .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
|
||||||
|
.reco h2{font-family:'Merriweather',Georgia,serif;font-size:26px;font-weight:700;margin-top:6px}
|
||||||
|
.reco .why{font-size:13.5px;line-height:1.85;color:rgba(255,255,255,.88);max-width:780px;margin-top:14px}
|
||||||
|
.reco ul{list-style:none;margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:9px 26px}
|
||||||
|
.reco ul li{font-size:12.5px;color:rgba(255,255,255,.9);padding-left:22px;position:relative;line-height:1.6}
|
||||||
|
.reco ul li::before{content:"✓";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||||
|
|
||||||
|
/* ─────────── Impl-ref ─────────── */
|
||||||
|
.impl{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||||
|
.impl h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:16px}
|
||||||
|
.impl h3{font-family:'Merriweather',Georgia,serif;font-size:15px;color:#002850;margin:22px 0 10px}
|
||||||
|
.impl-table{width:100%;border-collapse:collapse;margin-top:6px;font-size:12px}
|
||||||
|
.impl-table th{text-align:left;font-size:9px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:#002850;padding:8px 10px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
|
||||||
|
.impl-table td{padding:10px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.55}
|
||||||
|
.impl-table td:first-child{font-weight:700;color:#002850;width:22%}
|
||||||
|
.impl-table td code{font-family:'SF Mono','Menlo',monospace;font-size:11px;background:#F0EEE8;padding:1px 6px;border-radius:2px;color:#002850}
|
||||||
|
.impl-table td.px{color:#777;font-size:11.5px;width:16%}
|
||||||
|
.impl-table td.note{color:#888;font-size:11.5px;font-style:italic;width:22%}
|
||||||
|
.impl h3.ix{margin-top:32px}
|
||||||
|
|
||||||
|
.notes{background:#F9F8F5;border-left:3px solid #A6DAD8;padding:16px 22px;border-radius:0 4px 4px 0;margin-top:26px}
|
||||||
|
.notes .nh{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#002850;margin-bottom:8px}
|
||||||
|
.notes ul{list-style:none;display:flex;flex-direction:column;gap:6px}
|
||||||
|
.notes li{font-size:12px;color:#333;padding-left:18px;position:relative;line-height:1.7}
|
||||||
|
.notes li::before{content:"•";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<!-- ═══════════════ MASTHEAD ══════════════ -->
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<div class="mh">
|
||||||
|
<div class="kicker">UX Spec · Bulk Upload</div>
|
||||||
|
<h1>Uploading multiple documents in a single pass</h1>
|
||||||
|
<p>
|
||||||
|
Extends issue <strong>#294</strong> (new-document split-panel) with bulk uploads. When a user drops
|
||||||
|
N files, every metadata field applies once to all of them — only the <em>title</em> is per-file,
|
||||||
|
pre-filled from the filename and editable inline. A single save POST creates N documents.
|
||||||
|
</p>
|
||||||
|
<div class="byline">Prepared by Leonie Voss · 2026-04-24 · Draft 1 · References: #294, #305</div>
|
||||||
|
<div class="tag-row">
|
||||||
|
<span class="tag">feature</span>
|
||||||
|
<span class="tag mint">ui</span>
|
||||||
|
<span class="tag gray">a11y 320px+</span>
|
||||||
|
<span class="tag green">backend ready</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Goals -->
|
||||||
|
<div class="goals">
|
||||||
|
<h2>Design goals</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>One-pass feel</strong>: drop → fill shared fields → save. No wizard, no per-file detour.</li>
|
||||||
|
<li><strong>Every field is shared except the title</strong>, which is always set (filename-derived).</li>
|
||||||
|
<li><strong>No mode switch</strong>: 1 file and N files use the same screen — more files reveal more chrome.</li>
|
||||||
|
<li><strong>Scales to 20+ files</strong> without the form losing scan-ability on mobile.</li>
|
||||||
|
<li><strong>Reuses the #294 split-panel layout</strong> (DocumentEditLayout) — minimum new surface.</li>
|
||||||
|
<li><strong>a11y-first</strong>: 44px targets, focus states, `aria-current` on active file, keyboard-navigable.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<!-- ═════════ CONCEPT A — STACK ═════════ -->
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<section class="concept">
|
||||||
|
<div class="concept-header">
|
||||||
|
<div class="concept-num">A</div>
|
||||||
|
<div>
|
||||||
|
<div class="concept-label">Concept A</div>
|
||||||
|
<div class="concept-title">Flat Stack — shared header · file cards · sticky save</div>
|
||||||
|
<p class="concept-desc">
|
||||||
|
A single vertical flow: drop zone on top, then a <em>Gilt für alle</em> metadata card,
|
||||||
|
then stacked file cards (thumbnail · editable title · remove). No split panel, no tabs.
|
||||||
|
Scrolling down reveals all files; the save bar sticks to the bottom.
|
||||||
|
</p>
|
||||||
|
<div class="concept-best">
|
||||||
|
<span class="best-label">Best for</span>
|
||||||
|
<span class="best-text">Small-screen workflows. Seniors who prefer linear flows over tabs.</span>
|
||||||
|
</div>
|
||||||
|
<div class="concept-tradeoff">
|
||||||
|
Trade-off: no PDF preview until you click through to the document after save. Harder to verify
|
||||||
|
you grabbed the right files before committing.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- mobile mockup -->
|
||||||
|
<div class="screen narrow">
|
||||||
|
<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 class="viewport-hint">375 · mobile</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">Familienarchiv</div>
|
||||||
|
<div class="app-nav-r">
|
||||||
|
<div class="app-avatar">MR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-top-bar">
|
||||||
|
<div class="ca-back">← Zurück</div>
|
||||||
|
<div class="ca-title">Neue Dokumente</div>
|
||||||
|
<div class="ca-count">5</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-body" style="height:500px">
|
||||||
|
<!-- drop zone -->
|
||||||
|
<div class="ca-drop">
|
||||||
|
<div class="ca-drop-icon">⇪</div>
|
||||||
|
<div class="ca-drop-title">Weitere Dateien hinzufügen</div>
|
||||||
|
<div class="ca-drop-sub">PDF, JPEG, PNG, TIFF · max 50 MB</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- shared card -->
|
||||||
|
<div class="ca-shared-card">
|
||||||
|
<div class="ca-shared-head">
|
||||||
|
<span class="ca-shared-badge">Gilt für alle 5</span>
|
||||||
|
<span class="ca-shared-title">Angaben</span>
|
||||||
|
</div>
|
||||||
|
<div class="ca-shared-grid">
|
||||||
|
<div class="ca-shared-field">
|
||||||
|
<span class="f-label">Absender</span>
|
||||||
|
<div class="f-input filled">Hans Müller</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-shared-field">
|
||||||
|
<span class="f-label">Empfänger</span>
|
||||||
|
<div class="f-input filled">Anna Schmidt</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-shared-field">
|
||||||
|
<span class="f-label">Datum</span>
|
||||||
|
<div class="f-input filled">1950-06</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-shared-field">
|
||||||
|
<span class="f-label">Ort</span>
|
||||||
|
<div class="f-input empty">Berlin</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-shared-field full">
|
||||||
|
<span class="f-label">Tags</span>
|
||||||
|
<div class="f-tags">
|
||||||
|
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
|
||||||
|
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- files list -->
|
||||||
|
<div class="ca-files-head">
|
||||||
|
<div class="ca-files-title">5 Dateien · Titel bearbeiten</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-file active">
|
||||||
|
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl m"></div><div class="tl s"></div></div>
|
||||||
|
<div class="ca-file-body">
|
||||||
|
<div class="ca-file-title">Brief_1940_Hans</div>
|
||||||
|
<div class="ca-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-file-rm">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-file">
|
||||||
|
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl"></div><div class="tl s"></div></div>
|
||||||
|
<div class="ca-file-body">
|
||||||
|
<div class="ca-file-title">Brief_1940_Anna</div>
|
||||||
|
<div class="ca-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-file-rm">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-file">
|
||||||
|
<div class="ca-thumb"><div class="tl h"></div><div class="tl m"></div><div class="tl"></div><div class="tl"></div></div>
|
||||||
|
<div class="ca-file-body">
|
||||||
|
<div class="ca-file-title">Brief_1941_Clara</div>
|
||||||
|
<div class="ca-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-file-rm">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-file">
|
||||||
|
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl s"></div><div class="tl m"></div></div>
|
||||||
|
<div class="ca-file-body">
|
||||||
|
<div class="ca-file-title placeholder">Postkarte_Venedig</div>
|
||||||
|
<div class="ca-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
|
||||||
|
</div>
|
||||||
|
<div class="ca-file-rm">✕</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-bar">
|
||||||
|
<div class="btn-skip">Alle verwerfen</div>
|
||||||
|
<div class="btn-spacer"></div>
|
||||||
|
<div class="btn-outline">Als Platzhalter</div>
|
||||||
|
<div class="btn-primary">5 speichern →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<!-- ═══ CONCEPT B — SPLIT-PANEL + SWITCHER ══ -->
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<section class="concept">
|
||||||
|
<div class="concept-header">
|
||||||
|
<div class="concept-num">B</div>
|
||||||
|
<div>
|
||||||
|
<div class="concept-label">Concept B · RECOMMENDED</div>
|
||||||
|
<div class="concept-title">Split-Panel with File Switcher</div>
|
||||||
|
<p class="concept-desc">
|
||||||
|
Reuses the <em>DocumentEditLayout</em> from issue #294 and adds a horizontal file-switcher strip
|
||||||
|
under the PDF preview. Right column splits into two cards: <em>Gilt nur für diese Datei</em>
|
||||||
|
(title only, mint accent) and <em>Gilt für alle N Dokumente</em> (everything else).
|
||||||
|
When N=1 the switcher disappears and the screen is byte-identical to #294.
|
||||||
|
</p>
|
||||||
|
<div class="concept-best">
|
||||||
|
<span class="best-label">Best for</span>
|
||||||
|
<span class="best-text">The project's primary use case. Desktop + tablet, matches #294 DNA.</span>
|
||||||
|
</div>
|
||||||
|
<div class="concept-tradeoff">
|
||||||
|
Trade-off: on mobile the split has to collapse into tabs ("Vorschau / Angaben"). We reuse the
|
||||||
|
same responsive pattern that DocumentEditLayout already ships with.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- desktop mockup -->
|
||||||
|
<div class="screen">
|
||||||
|
<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 class="viewport-hint">1280 · desktop</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">Familienarchiv</div>
|
||||||
|
<div class="app-link on">Dokumente</div>
|
||||||
|
<div class="app-link">Personen</div>
|
||||||
|
<div class="app-link">Briefwechsel</div>
|
||||||
|
<div class="app-link">Chronik</div>
|
||||||
|
<div class="app-nav-r">
|
||||||
|
<div class="app-avatar">MR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-top-bar">
|
||||||
|
<div class="cb-back">← Dokumente</div>
|
||||||
|
<div class="cb-title">Neue Dokumente</div>
|
||||||
|
<div class="cb-count">5 werden erstellt</div>
|
||||||
|
<div class="cb-discard">Alle verwerfen</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cb-split">
|
||||||
|
<!-- PDF side -->
|
||||||
|
<div class="cb-pdf">
|
||||||
|
<div class="cb-pdf-toolbar">
|
||||||
|
<div class="cb-pdf-btn">◀</div>
|
||||||
|
<div class="cb-pdf-btn">▶</div>
|
||||||
|
<div class="cb-pdf-btn">+</div>
|
||||||
|
<div class="cb-pdf-btn">−</div>
|
||||||
|
<div class="cb-pdf-page">Seite 1 / 2 · Datei 1 von 5</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-pdf-view">
|
||||||
|
<div class="cb-paper">
|
||||||
|
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
|
||||||
|
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
|
||||||
|
<div class="pl s"></div><div class="pl sp"></div>
|
||||||
|
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
|
||||||
|
<div class="pl"></div><div class="pl s"></div><div class="pl sp"></div>
|
||||||
|
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- file switcher -->
|
||||||
|
<div class="cb-filebar">
|
||||||
|
<div class="cb-fb-arrow">‹</div>
|
||||||
|
<div class="cb-fb-track">
|
||||||
|
<div class="cb-fb-item on"><span class="cb-fb-num">1</span> Brief_1940_Hans.pdf</div>
|
||||||
|
<div class="cb-fb-item"><span class="cb-fb-num">2</span> Brief_1940_Anna.pdf</div>
|
||||||
|
<div class="cb-fb-item"><span class="cb-fb-num">3</span> Brief_1941_Clara.pdf</div>
|
||||||
|
<div class="cb-fb-item"><span class="cb-fb-num">4</span> Postkarte_Venedig.jpg</div>
|
||||||
|
<div class="cb-fb-item"><span class="cb-fb-num">5</span> Urkunde_1942.pdf</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-fb-arrow">›</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form side -->
|
||||||
|
<div class="cb-form">
|
||||||
|
<div class="cb-form-scroll">
|
||||||
|
<!-- PER-FILE card -->
|
||||||
|
<div class="cb-only-card">
|
||||||
|
<div class="cb-only-head">
|
||||||
|
<span class="cb-only-badge">Nur diese Datei</span>
|
||||||
|
<span class="cb-only-subtitle">1 / 5 · Brief_1940_Hans.pdf</span>
|
||||||
|
</div>
|
||||||
|
<div class="cb-row full">
|
||||||
|
<div class="cb-field">
|
||||||
|
<span class="f-label">Titel <span class="f-req">*</span></span>
|
||||||
|
<div class="f-input filled tall">Brief an Anna, 1940</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SHARED card -->
|
||||||
|
<div class="cb-shared-card">
|
||||||
|
<div class="cb-shared-head">
|
||||||
|
<span class="cb-shared-badge">Gilt für alle 5</span>
|
||||||
|
<span class="cb-shared-subtitle">Gemeinsame Angaben</span>
|
||||||
|
</div>
|
||||||
|
<div class="cb-row">
|
||||||
|
<div class="cb-field">
|
||||||
|
<span class="f-label">Absender</span>
|
||||||
|
<div class="f-input filled">Hans Müller</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-field">
|
||||||
|
<span class="f-label">Empfänger</span>
|
||||||
|
<div class="f-input filled">Anna Schmidt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-row">
|
||||||
|
<div class="cb-field">
|
||||||
|
<span class="f-label">Datum</span>
|
||||||
|
<div class="f-input filled">15.06.1950</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-field">
|
||||||
|
<span class="f-label">Ort</span>
|
||||||
|
<div class="f-input empty">z.B. Berlin</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-row full">
|
||||||
|
<div class="cb-field">
|
||||||
|
<span class="f-label">Tags</span>
|
||||||
|
<div class="f-tags">
|
||||||
|
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
|
||||||
|
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
|
||||||
|
<span class="f-chip">Briefwechsel <span class="f-chip-rm">×</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-row">
|
||||||
|
<div class="cb-field">
|
||||||
|
<span class="f-label">Archivbox</span>
|
||||||
|
<div class="f-input empty">z.B. B-12</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-field">
|
||||||
|
<span class="f-label">Mappe</span>
|
||||||
|
<div class="f-input empty">z.B. M-3</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-bar">
|
||||||
|
<div class="btn-skip">Alle verwerfen</div>
|
||||||
|
<div class="btn-spacer"></div>
|
||||||
|
<div class="btn-outline">Als Platzhalter</div>
|
||||||
|
<div class="btn-primary green">5 speichern →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<!-- ══ CONCEPT C — PROGRESSIVE ACCORDION ══ -->
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<section class="concept">
|
||||||
|
<div class="concept-header">
|
||||||
|
<div class="concept-num">C</div>
|
||||||
|
<div>
|
||||||
|
<div class="concept-label">Concept C</div>
|
||||||
|
<div class="concept-title">Progressive Accordion — shared sticky header · file cards expand inline</div>
|
||||||
|
<p class="concept-desc">
|
||||||
|
Shared metadata sticks at the top of the page. Below, each file is a collapsed card; clicking
|
||||||
|
a card expands it to show the PDF preview + title field inline. Only one card is expanded at a
|
||||||
|
time. Scales well to 20+ files — the list stays readable, you only look at the PDFs you want
|
||||||
|
to verify.
|
||||||
|
</p>
|
||||||
|
<div class="concept-best">
|
||||||
|
<span class="best-label">Best for</span>
|
||||||
|
<span class="best-text">Large batches (10+ files) where you want to spot-check a few.</span>
|
||||||
|
</div>
|
||||||
|
<div class="concept-tradeoff">
|
||||||
|
Trade-off: two different visual languages — cards collapsed vs. cards expanded with PDF. New
|
||||||
|
pattern for the project; costs familiarity.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="screen">
|
||||||
|
<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 class="viewport-hint">1280 · desktop</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">Familienarchiv</div>
|
||||||
|
<div class="app-link on">Dokumente</div>
|
||||||
|
<div class="app-link">Personen</div>
|
||||||
|
<div class="app-nav-r"><div class="app-avatar">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-top-bar">
|
||||||
|
<div class="ca-back">← Zurück</div>
|
||||||
|
<div class="ca-title">Neue Dokumente</div>
|
||||||
|
<div class="ca-count">5</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cc-body">
|
||||||
|
<!-- sticky shared card -->
|
||||||
|
<div class="cc-shared">
|
||||||
|
<div class="cc-shared-head">
|
||||||
|
<span class="cc-shared-badge">Gilt für alle 5</span>
|
||||||
|
<span class="cc-shared-title">Gemeinsame Angaben</span>
|
||||||
|
</div>
|
||||||
|
<div class="cc-grid">
|
||||||
|
<div class="cb-field"><span class="f-label">Absender</span><div class="f-input filled">Hans Müller</div></div>
|
||||||
|
<div class="cb-field"><span class="f-label">Empfänger</span><div class="f-input filled">Anna Schmidt</div></div>
|
||||||
|
<div class="cb-field"><span class="f-label">Datum</span><div class="f-input filled">15.06.1950</div></div>
|
||||||
|
<div class="cb-field span2"><span class="f-label">Tags</span><div class="f-tags"><span class="f-chip">Familie <span class="f-chip-rm">×</span></span><span class="f-chip">Krieg <span class="f-chip-rm">×</span></span></div></div>
|
||||||
|
<div class="cb-field"><span class="f-label">Ort</span><div class="f-input empty">z.B. Berlin</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cc-files-label">5 Dateien</div>
|
||||||
|
|
||||||
|
<!-- collapsed card -->
|
||||||
|
<div class="cc-file">
|
||||||
|
<div class="cc-file-head">
|
||||||
|
<div class="cc-caret">▸</div>
|
||||||
|
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||||
|
<div class="cc-file-body">
|
||||||
|
<div class="cc-file-titlerow">
|
||||||
|
<div class="cc-file-title">Brief an Anna, 1940</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-rm">✕</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- expanded card -->
|
||||||
|
<div class="cc-file open">
|
||||||
|
<div class="cc-file-head open">
|
||||||
|
<div class="cc-caret" style="color:#002850">▾</div>
|
||||||
|
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||||
|
<div class="cc-file-body">
|
||||||
|
<div class="cc-file-titlerow">
|
||||||
|
<div class="cc-file-title">Brief von Anna, Antwort</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-rm">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-open">
|
||||||
|
<div class="cc-preview">
|
||||||
|
<div class="cc-preview-paper">
|
||||||
|
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
|
||||||
|
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
|
||||||
|
<div class="pl sp"></div>
|
||||||
|
<div class="pl"></div><div class="pl"></div><div class="pl m"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-form">
|
||||||
|
<div class="cb-only-head">
|
||||||
|
<span class="cb-only-badge">Nur diese Datei</span>
|
||||||
|
<span class="cb-only-subtitle">2 / 5</span>
|
||||||
|
</div>
|
||||||
|
<div class="cb-field">
|
||||||
|
<span class="f-label">Titel <span class="f-req">*</span></span>
|
||||||
|
<div class="f-input filled tall">Brief von Anna, Antwort</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- more collapsed -->
|
||||||
|
<div class="cc-file">
|
||||||
|
<div class="cc-file-head">
|
||||||
|
<div class="cc-caret">▸</div>
|
||||||
|
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||||
|
<div class="cc-file-body">
|
||||||
|
<div class="cc-file-titlerow">
|
||||||
|
<div class="cc-file-title placeholder">Brief_1941_Clara</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-rm">✕</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file">
|
||||||
|
<div class="cc-file-head">
|
||||||
|
<div class="cc-caret">▸</div>
|
||||||
|
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||||
|
<div class="cc-file-body">
|
||||||
|
<div class="cc-file-titlerow">
|
||||||
|
<div class="cc-file-title placeholder">Postkarte_Venedig</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-rm">✕</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file">
|
||||||
|
<div class="cc-file-head">
|
||||||
|
<div class="cc-caret">▸</div>
|
||||||
|
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||||
|
<div class="cc-file-body">
|
||||||
|
<div class="cc-file-titlerow">
|
||||||
|
<div class="cc-file-title placeholder">Urkunde_1942</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-meta">Urkunde_1942.pdf · 3.1 MB</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-file-rm">✕</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-bar">
|
||||||
|
<div class="btn-skip">Alle verwerfen</div>
|
||||||
|
<div class="btn-spacer"></div>
|
||||||
|
<div class="btn-outline">Als Platzhalter</div>
|
||||||
|
<div class="btn-primary green">5 speichern →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<!-- ══════════ DECISION MATRIX ════════════ -->
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<div class="decision">
|
||||||
|
<h2>Decision matrix</h2>
|
||||||
|
<p class="lead">
|
||||||
|
All three concepts meet the core requirement (shared metadata + per-file title + one save).
|
||||||
|
Graded against what matters for the senior audience, the responsive constraint, and the #294
|
||||||
|
architectural commitment.
|
||||||
|
</p>
|
||||||
|
<table class="dm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Dimension</th>
|
||||||
|
<th>A · Stack</th>
|
||||||
|
<th>B · Split-Panel</th>
|
||||||
|
<th>C · Accordion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Reuses #294 layout</td>
|
||||||
|
<td class="score bad">✕</td>
|
||||||
|
<td class="score ok">✓</td>
|
||||||
|
<td class="score bad">✕</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Single-file mode unchanged</td>
|
||||||
|
<td class="score mid">rewrite</td>
|
||||||
|
<td class="score ok">identical</td>
|
||||||
|
<td class="score bad">different</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>PDF visible before save</td>
|
||||||
|
<td class="score bad">no</td>
|
||||||
|
<td class="score ok">always</td>
|
||||||
|
<td class="score mid">one at a time</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Works at 320px</td>
|
||||||
|
<td class="score ok">native</td>
|
||||||
|
<td class="score mid">via tab collapse</td>
|
||||||
|
<td class="score ok">native</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Scales to 20 files</td>
|
||||||
|
<td class="score mid">long scroll</td>
|
||||||
|
<td class="score ok">switcher scrolls</td>
|
||||||
|
<td class="score ok">collapsed list</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>New Svelte components</td>
|
||||||
|
<td class="score bad">3 new</td>
|
||||||
|
<td class="score ok">1 new (switcher)</td>
|
||||||
|
<td class="score bad">4 new</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Familiar pattern</td>
|
||||||
|
<td class="score ok">yes</td>
|
||||||
|
<td class="score ok">yes (post-#294)</td>
|
||||||
|
<td class="score mid">new to app</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<!-- ══════════ RECOMMENDATION ════════════ -->
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<div class="reco">
|
||||||
|
<div class="kicker">Recommendation</div>
|
||||||
|
<h2>Ship Concept B</h2>
|
||||||
|
<p class="why">
|
||||||
|
Concept B treats bulk upload as a <em>polymorphic state</em> of the existing single-document
|
||||||
|
layout rather than a separate screen. A user who drops one file gets exactly the #294 experience.
|
||||||
|
A user who drops five gets the same screen plus a horizontal file-switcher and a two-card split
|
||||||
|
(<em>Nur diese Datei</em> vs. <em>Gilt für alle</em>). Nothing about the single-file flow changes.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Keeps the mental model: "one form, one save" regardless of file count.</li>
|
||||||
|
<li>PDF preview is persistent — you can spot-check each scan before committing.</li>
|
||||||
|
<li>The per-file title is visually promoted with a mint border so it reads as the one thing that differs per file.</li>
|
||||||
|
<li>Reuses DocumentEditLayout: the delta is ~1 new component (<code>FileSwitcherStrip</code>) + two cards in the form.</li>
|
||||||
|
<li>Single-file mode is byte-identical to #294 — no regression risk for existing users.</li>
|
||||||
|
<li>Backend is already ready (<code>POST /api/documents/quick-upload</code> accepts N files in one multipart).</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<!-- ══════════ IMPL-REF · CONCEPT B ═══════ -->
|
||||||
|
<!-- ════════════════════════════════════════════ -->
|
||||||
|
<div class="impl">
|
||||||
|
<h2>Implementation reference — Concept B</h2>
|
||||||
|
|
||||||
|
<h3>Top bar (when N > 1)</h3>
|
||||||
|
<table class="impl-table">
|
||||||
|
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Count pill "N werden erstellt"</td>
|
||||||
|
<td><code>bg-accent text-primary rounded-full px-3 py-1 text-sm font-bold</code></td>
|
||||||
|
<td class="px">14px · 700</td>
|
||||||
|
<td class="note">brand-mint on brand-navy</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>"Alle verwerfen" link</td>
|
||||||
|
<td><code>ml-auto text-sm font-bold text-red-600 hover:text-red-800 focus-visible:outline-2 focus-visible:outline-red-600</code></td>
|
||||||
|
<td class="px">14px / 44px target</td>
|
||||||
|
<td class="note">confirm dialog before wiping</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="ix">FileSwitcherStrip (new component)</h3>
|
||||||
|
<table class="impl-table">
|
||||||
|
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Strip container</td>
|
||||||
|
<td><code>flex items-center gap-1 bg-ink/95 px-2 py-2 border-t border-ink/80</code></td>
|
||||||
|
<td class="px">height 48px</td>
|
||||||
|
<td class="note">under the PDF toolbar, on the dark panel</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Arrow buttons</td>
|
||||||
|
<td><code>h-10 w-10 rounded-sm bg-white/8 text-surface/60 hover:bg-white/15 focus-visible:outline-2</code></td>
|
||||||
|
<td class="px">40×40 (44 w/padding)</td>
|
||||||
|
<td class="note"><code>aria-label="Vorherige Datei"</code> / "Nächste Datei"</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>File chip (inactive)</td>
|
||||||
|
<td><code>px-3 py-2 rounded-sm bg-white/6 text-sm font-bold text-surface/55 whitespace-nowrap hover:bg-white/12</code></td>
|
||||||
|
<td class="px">14px / h 40px</td>
|
||||||
|
<td class="note">horizontal scroll container uses <code>snap-x snap-mandatory</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>File chip (active)</td>
|
||||||
|
<td><code>... bg-accent text-primary</code> + <code>aria-current="true"</code></td>
|
||||||
|
<td class="px">14px / h 40px</td>
|
||||||
|
<td class="note">mint pill, primary text — 7.2:1 contrast passes AAA</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Chip number prefix</td>
|
||||||
|
<td><code>bg-primary/25 rounded-sm px-1 mr-2 text-xs font-extrabold</code></td>
|
||||||
|
<td class="px">12px / 800</td>
|
||||||
|
<td class="note">"1", "2", … — for quick scanning</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="ix">"Nur diese Datei" card (per-file scope)</h3>
|
||||||
|
<table class="impl-table">
|
||||||
|
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Card container</td>
|
||||||
|
<td><code>bg-accent/20 border border-accent rounded-sm p-4 mb-4</code></td>
|
||||||
|
<td class="px">padding 16px</td>
|
||||||
|
<td class="note">mint tint signals "different per file"</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Scope badge</td>
|
||||||
|
<td><code>bg-primary/90 text-accent rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
|
||||||
|
<td class="px">12px · 800</td>
|
||||||
|
<td class="note">Paraglide key: <code>bulk_only_this_file</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Title input</td>
|
||||||
|
<td><code>h-11 text-base font-semibold text-ink bg-white border border-line rounded-sm px-3 focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-ink/20</code></td>
|
||||||
|
<td class="px">44px min-height · 16px</td>
|
||||||
|
<td class="note">pre-filled from filename <em>without extension</em></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="ix">"Gilt für alle" card (shared scope)</h3>
|
||||||
|
<table class="impl-table">
|
||||||
|
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Card container</td>
|
||||||
|
<td><code>bg-surface border border-line rounded-sm p-4 mb-3</code></td>
|
||||||
|
<td class="px">padding 16px</td>
|
||||||
|
<td class="note">neutral (no accent tint)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Scope badge</td>
|
||||||
|
<td><code>bg-accent text-primary rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
|
||||||
|
<td class="px">12px · 800</td>
|
||||||
|
<td class="note">Paraglide: <code>bulk_shared_count</code> ("Gilt für alle {count}")</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Field grid</td>
|
||||||
|
<td><code>grid grid-cols-1 md:grid-cols-2 gap-3</code></td>
|
||||||
|
<td class="px">12px gap</td>
|
||||||
|
<td class="note">single column at 320px, two at ≥ 768px</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="ix">Save bar</h3>
|
||||||
|
<table class="impl-table">
|
||||||
|
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Primary save button</td>
|
||||||
|
<td><code>h-11 px-5 bg-green-700 hover:bg-green-800 text-white font-extrabold rounded-sm text-sm focus-visible:ring-2 focus-visible:ring-green-900</code></td>
|
||||||
|
<td class="px">44px min · 14px</td>
|
||||||
|
<td class="note">label <code>{count} speichern →</code> (plural-aware Paraglide)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>"Als Platzhalter" (outline)</td>
|
||||||
|
<td><code>h-11 px-4 border border-line bg-white text-ink-3 font-bold rounded-sm text-sm</code></td>
|
||||||
|
<td class="px">44px</td>
|
||||||
|
<td class="note">posts with <code>metadataComplete=false</code> for all</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="ix">Responsive collapse (≤ 767px)</h3>
|
||||||
|
<table class="impl-table">
|
||||||
|
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Panel mode switch</td>
|
||||||
|
<td>reuses DocumentEditLayout's existing tab collapse — "Vorschau / Angaben" tabs</td>
|
||||||
|
<td class="px">tab height 48px</td>
|
||||||
|
<td class="note">already shipped with #294</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>File switcher stays on "Vorschau" tab</td>
|
||||||
|
<td><code>snap-x snap-mandatory overflow-x-auto</code></td>
|
||||||
|
<td class="px">h 44px</td>
|
||||||
|
<td class="note">horizontal swipe; arrow buttons removed at mobile</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<div class="nh">Interactions + behaviour</div>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Drop a file after the initial batch</strong>: append to the end of the list and switch focus to the newly added file. No modal, no confirmation.</li>
|
||||||
|
<li><strong>Remove a file</strong> (X on the chip) → confirm only if it's the currently-previewed one; otherwise silent. When count drops to 1 the switcher strip animates away (200ms); when it drops to 0 we redirect back to the drop-zone state.</li>
|
||||||
|
<li><strong>Title auto-fill</strong>: <code>filename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim()</code>. Marks the title input as <code>suggested</code> until the user edits it (mint left border, same treatment as #294's filename-derived fields).</li>
|
||||||
|
<li><strong>Title field visibility</strong>: always rendered (never collapsed) even in single-file mode, so there's zero layout jump when N changes from 1 to 2.</li>
|
||||||
|
<li><strong>Save flow</strong>: single POST to <code>/api/documents/quick-upload</code> with N files + JSON metadata object containing shared fields + titles array. Backend maps title[i] to files[i] by index. Response splits into <code>created[] / updated[] / errors[]</code> — show a summary toast + inline error markers per file for the <code>errors[]</code> list.</li>
|
||||||
|
<li><strong>Keyboard navigation</strong>: <kbd>←</kbd>/<kbd>→</kbd> on the switcher strip moves file focus; <kbd>Tab</kbd> cycles through form fields inside whichever card is active; <kbd>Esc</kbd> on the discard button opens the confirm dialog.</li>
|
||||||
|
<li><strong>Focus management on file switch</strong>: when the user clicks a different file, the title input of the new file receives focus automatically (so the main editable field is always reachable).</li>
|
||||||
|
<li><strong>Progress indicator during save</strong>: replace the save button with a determinate progress bar showing "Lade Datei 3 von 5…" for batches that take > 500ms.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes" style="margin-top:14px;border-left-color:#C0392B">
|
||||||
|
<div class="nh" style="color:#C0392B">Edge cases + a11y</div>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Duplicate filenames in the batch</strong>: accept, but show a warning icon next to both — backend will create both with unique IDs.</li>
|
||||||
|
<li><strong>Mixed content types</strong>: PDF + image in the same batch is fine; the preview panel renders whichever the active file is (DocumentEditLayout already handles both).</li>
|
||||||
|
<li><strong>Large batches (> 20 files)</strong>: the switcher strip becomes scrollable; consider a "Jump to file…" combobox at > 30 files (out of scope for v1).</li>
|
||||||
|
<li><strong>Upload failure per file</strong>: mark the chip red (<code>bg-red-600/20 text-red-800 border border-red-600</code>), show inline error in the chip's tooltip, don't block the rest of the batch from retrying.</li>
|
||||||
|
<li><strong>Screen reader announcement</strong>: when file count changes, fire a polite live region announce — "5 Dateien bereit zum Speichern" via <code>role="status" aria-live="polite"</code>.</li>
|
||||||
|
<li><strong>Colour-alone warning</strong>: active file chip uses color + <code>aria-current="true"</code> + a ▸ caret prefix so it's distinguishable for color-blind users.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1684
docs/specs/bulk-upload-split-panel-spec.html
Normal file
1684
docs/specs/bulk-upload-split-panel-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
65
frontend/e2e/briefwechsel-a11y.spec.ts
Normal file
65
frontend/e2e/briefwechsel-a11y.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedBilateralPair,
|
||||||
|
cleanupBilateralPair,
|
||||||
|
type BilateralPair
|
||||||
|
} from './fixtures/bilateral-correspondence';
|
||||||
|
|
||||||
|
// Accessibility coverage for the briefwechsel thumbnail-row layout. Seeds
|
||||||
|
// two persons + a bilateral document via the shared fixture so the page
|
||||||
|
// reaches the results state (not the hero), then runs axe-core
|
||||||
|
// (wcag2a + wcag2aa) across three viewports and two color schemes.
|
||||||
|
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ name: 'mobile', width: 375, height: 812 },
|
||||||
|
{ name: 'tablet', width: 768, height: 1024 },
|
||||||
|
{ name: 'desktop', width: 1280, height: 800 }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const THEMES = ['light', 'dark'] as const;
|
||||||
|
|
||||||
|
let pair: BilateralPair;
|
||||||
|
|
||||||
|
test.describe('Accessibility — /briefwechsel row layout', () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
pair = await seedBilateralPair(request, 'A11y');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await cleanupBilateralPair(request, pair);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
for (const theme of THEMES) {
|
||||||
|
test(`${vp.name} / ${theme} has no wcag2a/wcag2aa violations`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await page.goto(
|
||||||
|
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
|
||||||
|
);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Assert we actually reached the row layout, not the hero — otherwise
|
||||||
|
// the axe sweep silently scans the wrong DOM.
|
||||||
|
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
|
||||||
|
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.withTags(['wcag2a', 'wcag2aa'])
|
||||||
|
.include('main')
|
||||||
|
.analyze();
|
||||||
|
|
||||||
|
if (results.violations.length > 0) {
|
||||||
|
const summary = results.violations
|
||||||
|
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||||
|
.join('\n');
|
||||||
|
console.log(
|
||||||
|
`\nAccessibility violations on briefwechsel (${vp.name}/${theme}):\n${summary}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
79
frontend/e2e/briefwechsel-rows.visual.spec.ts
Normal file
79
frontend/e2e/briefwechsel-rows.visual.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedBilateralPair,
|
||||||
|
cleanupBilateralPair,
|
||||||
|
type BilateralPair
|
||||||
|
} from './fixtures/bilateral-correspondence';
|
||||||
|
|
||||||
|
// Visual + structural coverage for the new briefwechsel row layout.
|
||||||
|
//
|
||||||
|
// Seeds a bilateral correspondence pair via the shared fixture so the page
|
||||||
|
// reaches the row state. The structural test asserts that a
|
||||||
|
// ConversationThumbnail tile AND the DistributionBar render — regressions
|
||||||
|
// that silently drop to the hero or break the {#each} wiring fail here.
|
||||||
|
//
|
||||||
|
// Snapshot assertions are gated on the VISUAL env flag because they need
|
||||||
|
// pre-captured baselines (see `playwright test --update-snapshots` to
|
||||||
|
// regenerate after intentional UI changes). CI can opt in via VISUAL=1.
|
||||||
|
const VISUAL = process.env.VISUAL === '1';
|
||||||
|
|
||||||
|
let pair: BilateralPair;
|
||||||
|
|
||||||
|
test.describe('Briefwechsel — thumbnail-row layout', () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
pair = await seedBilateralPair(request, 'Visual');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await cleanupBilateralPair(request, pair);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openBilateral(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(
|
||||||
|
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
|
||||||
|
);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
// Parity with the a11y spec: fail loudly if we ever end up on the hero
|
||||||
|
// instead of the row layout.
|
||||||
|
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders a ConversationThumbnail tile and the DistributionBar', async ({ page }) => {
|
||||||
|
await openBilateral(page);
|
||||||
|
|
||||||
|
// Tile appears for the seeded document
|
||||||
|
await expect(page.locator('[data-testid="conv-thumb-tile"]').first()).toBeVisible();
|
||||||
|
|
||||||
|
// DistributionBar is present (role=img with a descriptive aria-label)
|
||||||
|
const bar = page.locator('[role="img"]');
|
||||||
|
await expect(bar).toBeVisible();
|
||||||
|
const label = (await bar.getAttribute('aria-label')) ?? '';
|
||||||
|
expect(label.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Visual regression — one snapshot per (viewport × theme). Tolerance stays
|
||||||
|
// generous (maxDiffPixels: 100) so antialiasing jitter doesn't flip them on
|
||||||
|
// unrelated runs; genuine layout changes are still caught because the
|
||||||
|
// thumbnail tile and distribution bar dominate the frame.
|
||||||
|
test.describe('snapshots', () => {
|
||||||
|
test.skip(!VISUAL, 'VISUAL=1 required to compare baselines');
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'mobile', width: 375, height: 812 },
|
||||||
|
{ name: 'tablet', width: 768, height: 1024 },
|
||||||
|
{ name: 'desktop', width: 1280, height: 800 }
|
||||||
|
] as const) {
|
||||||
|
for (const theme of ['light', 'dark'] as const) {
|
||||||
|
test(`${viewport.name} / ${theme}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await openBilateral(page);
|
||||||
|
await expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, {
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
fullPage: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
frontend/e2e/fixtures/bilateral-correspondence.ts
Normal file
62
frontend/e2e/fixtures/bilateral-correspondence.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fixture for the briefwechsel row layout.
|
||||||
|
*
|
||||||
|
* Creates two persons and one document with sender/receiver between them so
|
||||||
|
* that `/briefwechsel?senderId=X&receiverId=Y` navigates straight to the row
|
||||||
|
* state (not the hero). Each seed uses a `Date.now()`-suffixed last name so
|
||||||
|
* parallel runs and reruns never collide.
|
||||||
|
*
|
||||||
|
* The backend does not expose a person-delete endpoint, so only the document
|
||||||
|
* is cleaned up in {@link cleanupBilateralPair}. The two timestamped persons
|
||||||
|
* remain in the DB — acceptable for the test environment, and the unique
|
||||||
|
* suffix means they cannot conflict with later runs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BilateralPair {
|
||||||
|
senderId: string;
|
||||||
|
receiverId: string;
|
||||||
|
documentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedBilateralPair(
|
||||||
|
request: APIRequestContext,
|
||||||
|
prefix: string
|
||||||
|
): Promise<BilateralPair> {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
const senderRes = await request.post('/api/persons', {
|
||||||
|
data: { firstName: prefix, lastName: `Sender-${timestamp}` }
|
||||||
|
});
|
||||||
|
if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`);
|
||||||
|
const senderId = (await senderRes.json()).id as string;
|
||||||
|
|
||||||
|
const receiverRes = await request.post('/api/persons', {
|
||||||
|
data: { firstName: prefix, lastName: `Receiver-${timestamp}` }
|
||||||
|
});
|
||||||
|
if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`);
|
||||||
|
const receiverId = (await receiverRes.json()).id as string;
|
||||||
|
|
||||||
|
const docRes = await request.post('/api/documents', {
|
||||||
|
multipart: {
|
||||||
|
title: `${prefix} Brief`,
|
||||||
|
documentDate: '1950-06-15',
|
||||||
|
senderId,
|
||||||
|
receiverIds: receiverId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||||
|
const documentId = (await docRes.json()).id as string;
|
||||||
|
|
||||||
|
return { senderId, receiverId, documentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupBilateralPair(
|
||||||
|
request: APIRequestContext,
|
||||||
|
pair: BilateralPair
|
||||||
|
): Promise<void> {
|
||||||
|
// Only the document is purged — the backend has no person-delete endpoint
|
||||||
|
// and the timestamped last names make orphaned person rows safe to leave.
|
||||||
|
await request.delete(`/api/documents/${pair.documentId}`);
|
||||||
|
}
|
||||||
36
frontend/e2e/help-popover.spec.ts
Normal file
36
frontend/e2e/help-popover.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||||
|
|
||||||
|
test.describe('Help chip — Read/Edit panel header', () => {
|
||||||
|
let docId: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ 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 }) => {
|
||||||
|
await page.goto(`/documents/${docId}`);
|
||||||
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
|
||||||
|
// Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
|
||||||
|
const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
|
||||||
|
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
||||||
|
await helpBtn.click();
|
||||||
|
|
||||||
|
// Popover should open (role="region", not tooltip — click-triggered panels are regions)
|
||||||
|
await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
|
||||||
|
|
||||||
|
// Press Esc
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
// Focus should have returned to the chip
|
||||||
|
await expect(helpBtn).toBeFocused();
|
||||||
|
});
|
||||||
|
});
|
||||||
30
frontend/e2e/helpers/upload-empty-document.ts
Normal file
30
frontend/e2e/helpers/upload-empty-document.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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> {
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Transcribe Coach Test' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
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;
|
||||||
|
}
|
||||||
74
frontend/e2e/richtlinien.spec.ts
Normal file
74
frontend/e2e/richtlinien.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
|
||||||
|
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||||
|
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Richtlinien page — content', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/hilfe/transkription');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders h1 title, intro, five rules, four chips, closing card', async ({ page }) => {
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { level: 1, name: /Transkriptions-Richtlinien/ })
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText(/Damit alle Briefe einheitlich/)).toBeVisible();
|
||||||
|
await expect(page.getByText('Nicht lesbare Wörter')).toBeVisible();
|
||||||
|
await expect(page.getByText('Durchgestrichene Wörter')).toBeVisible();
|
||||||
|
await expect(page.getByText(/Das lange s/)).toBeVisible();
|
||||||
|
await expect(page.getByText('Unsichere Namen')).toBeVisible();
|
||||||
|
await expect(page.getByText(/Dialekt/)).toBeVisible();
|
||||||
|
await expect(page.getByText('Abkürzungen')).toBeVisible();
|
||||||
|
await expect(page.getByText('Datumsformate')).toBeVisible();
|
||||||
|
await expect(page.getByText(/Fehlt eine Regel/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wikipedia link opens in new tab with annotation', async ({ page }) => {
|
||||||
|
const wikiLink = page.getByRole('link', { name: /Wikipedia/ });
|
||||||
|
await expect(wikiLink).toHaveAttribute('target', '_blank');
|
||||||
|
await expect(wikiLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
await expect(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||||
|
await expect(wikiLink).toContainText(/öffnet in neuem Tab/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Richtlinien page — accessibility', () => {
|
||||||
|
for (const viewport of [320, 768, 1440]) {
|
||||||
|
test(`axe: light theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: viewport, height: 800 });
|
||||||
|
await page.goto('/hilfe/transkription');
|
||||||
|
const a11y = await buildAxe(page).analyze();
|
||||||
|
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`axe: dark theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: viewport, height: 800 });
|
||||||
|
await page.goto('/hilfe/transkription');
|
||||||
|
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
||||||
|
const a11y = await buildAxe(page).analyze();
|
||||||
|
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Richtlinien page — print media', () => {
|
||||||
|
test('print snapshot hides nav, annotation chip, and new-tab spans', async ({ page }) => {
|
||||||
|
await page.emulateMedia({ media: 'print' });
|
||||||
|
await page.goto('/hilfe/transkription');
|
||||||
|
|
||||||
|
const nav = page.locator('.app-nav');
|
||||||
|
if ((await nav.count()) > 0) {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
105
frontend/e2e/transcribe-coach.spec.ts
Normal file
105
frontend/e2e/transcribe-coach.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
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']) {
|
||||||
|
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Transcribe coach — empty state', () => {
|
||||||
|
let docId: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ 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 ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/documents/${docId}`);
|
||||||
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
await expect(page.getByText(/Kurrent-Erkenner lernt noch/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/Rahmen ziehen/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/Text eingeben/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/Speichert automatisch/)).toBeVisible();
|
||||||
|
await expect(page.getByRole('img', { name: /Rahmen ziehen|Animation/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
||||||
|
await page.goto(`/documents/${docId}`);
|
||||||
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
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 }) => {
|
||||||
|
await page.goto(`/documents/${docId}`);
|
||||||
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const a11y = await buildAxe(page).analyze();
|
||||||
|
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||||
|
await page.goto(`/documents/${docId}`);
|
||||||
|
// Toggle dark theme
|
||||||
|
await page.getByRole('button', { name: /dark mode/i }).click();
|
||||||
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const a11y = await buildAxe(page).analyze();
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
@@ -165,6 +169,10 @@
|
|||||||
"conv_hero_divider": "oder",
|
"conv_hero_divider": "oder",
|
||||||
"conv_empty_recent_label": "Zuletzt geöffnet",
|
"conv_empty_recent_label": "Zuletzt geöffnet",
|
||||||
"conv_no_party": "—",
|
"conv_no_party": "—",
|
||||||
|
"dist_bar_segment": "{count} von {name}",
|
||||||
|
"dist_bar_aria": "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}",
|
||||||
|
"row_direction_sent": "Gesendet",
|
||||||
|
"row_direction_received": "Empfangen",
|
||||||
"admin_heading": "Admin Dashboard",
|
"admin_heading": "Admin Dashboard",
|
||||||
"admin_tab_users": "Benutzer",
|
"admin_tab_users": "Benutzer",
|
||||||
"admin_tab_groups": "Gruppen",
|
"admin_tab_groups": "Gruppen",
|
||||||
@@ -495,7 +503,7 @@
|
|||||||
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
|
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
|
||||||
"transcription_block_history_btn": "Verlauf",
|
"transcription_block_history_btn": "Verlauf",
|
||||||
"transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen",
|
"transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen",
|
||||||
"transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen",
|
"transcription_next_block_cta": "Einen Rahmen ziehen, um Block {number} anzulegen",
|
||||||
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
|
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
|
||||||
"transcription_quote_stale": "Zitat aus älterer Version",
|
"transcription_quote_stale": "Zitat aus älterer Version",
|
||||||
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
||||||
@@ -511,7 +519,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.",
|
||||||
@@ -524,6 +531,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",
|
||||||
@@ -533,6 +541,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.",
|
||||||
@@ -802,5 +813,98 @@
|
|||||||
"chronik_load_more": "Mehr laden",
|
"chronik_load_more": "Mehr laden",
|
||||||
"chronik_loading": "Lädt …",
|
"chronik_loading": "Lädt …",
|
||||||
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
||||||
"chronik_view_all": "Alle Aktivitäten →"
|
"chronik_view_all": "Alle Aktivitäten →",
|
||||||
|
"pagination_prev": "Zurück",
|
||||||
|
"pagination_next": "Weiter",
|
||||||
|
"pagination_page_of": "Seite {page} von {total}",
|
||||||
|
"pagination_nav_label": "Seitennavigation",
|
||||||
|
|
||||||
|
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||||
|
|
||||||
|
"transcribe_coach_title": "Erste Transkription?",
|
||||||
|
"transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:",
|
||||||
|
"transcribe_coach_step_1_title": "Rahmen ziehen.",
|
||||||
|
"transcribe_coach_step_1_body": "Klicken und ziehen Sie mit der Maus einen Rahmen um den Text, den Sie transkribieren möchten.",
|
||||||
|
"transcribe_coach_step_2_title": "Text eingeben.",
|
||||||
|
"transcribe_coach_step_2_body": "Geben Sie den Text, den Sie im Rahmen sehen, in das neue Textfeld ein.",
|
||||||
|
"transcribe_coach_step_3_title": "Speichert automatisch.",
|
||||||
|
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
|
||||||
|
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
|
||||||
|
|
||||||
|
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus",
|
||||||
|
"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_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": "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_rules_label": "Regeln für die Transkription",
|
||||||
|
"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_durchgestrichen_title": "Durchgestrichene Wörter",
|
||||||
|
"richtlinien_rule_durchgestrichen_body": "Auch durchgestrichener Text gehört zum Brief. Schreiben Sie ihn in eckigen Klammern mit Präfix durchgestrichen:",
|
||||||
|
"richtlinien_rule_langes_s_title": "Das lange s (ſ)",
|
||||||
|
"richtlinien_rule_langes_s_body": "Das ſ ist nur eine alte Schriftform des Buchstabens s — kein eigener Laut. Schreiben Sie immer ein normales s.",
|
||||||
|
"richtlinien_rule_name_title": "Unsichere Namen",
|
||||||
|
"richtlinien_rule_name_body": "Wenn Sie einen Namen zu erkennen meinen, aber nicht sicher sind, ergänzen Sie ein Fragezeichen in eckigen Klammern.",
|
||||||
|
"richtlinien_rule_dialekt_title": "Dialekt, Fremdwörter, fremde Zitate",
|
||||||
|
"richtlinien_rule_dialekt_body": "Plattdeutsch, Französisch, lateinische Phrasen — wörtlich übernehmen, genau wie sie geschrieben stehen.",
|
||||||
|
"richtlinien_beispiel_label": "Beispiel",
|
||||||
|
"richtlinien_klaerung_label": "Noch in Klärung",
|
||||||
|
"richtlinien_klaerung_intro": "Diese Fragen klären wir noch — stoßen Sie beim Transkribieren darauf, treffen Sie eine plausible Wahl und notieren Sie es in den Kommentaren:",
|
||||||
|
"richtlinien_klaer_abkuerzungen": "Abkürzungen",
|
||||||
|
"richtlinien_klaer_datumsformate": "Datumsformate",
|
||||||
|
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
||||||
|
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
||||||
|
"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.",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -165,6 +169,10 @@
|
|||||||
"conv_hero_divider": "or",
|
"conv_hero_divider": "or",
|
||||||
"conv_empty_recent_label": "Recently opened",
|
"conv_empty_recent_label": "Recently opened",
|
||||||
"conv_no_party": "—",
|
"conv_no_party": "—",
|
||||||
|
"dist_bar_segment": "{count} from {name}",
|
||||||
|
"dist_bar_aria": "Letter distribution in this period: {outCount} from {senderName}, {inCount} from {receiverName}",
|
||||||
|
"row_direction_sent": "Sent",
|
||||||
|
"row_direction_received": "Received",
|
||||||
"admin_heading": "Admin Dashboard",
|
"admin_heading": "Admin Dashboard",
|
||||||
"admin_tab_users": "Users",
|
"admin_tab_users": "Users",
|
||||||
"admin_tab_groups": "Groups",
|
"admin_tab_groups": "Groups",
|
||||||
@@ -495,7 +503,7 @@
|
|||||||
"transcription_block_delete_confirm": "Really delete this block and all its comments?",
|
"transcription_block_delete_confirm": "Really delete this block and all its comments?",
|
||||||
"transcription_block_history_btn": "History",
|
"transcription_block_history_btn": "History",
|
||||||
"transcription_empty_cta": "Mark a region on the scan to start transcribing",
|
"transcription_empty_cta": "Mark a region on the scan to start transcribing",
|
||||||
"transcription_next_block_cta": "Mark another passage on the scan to create block {number}",
|
"transcription_next_block_cta": "Draw a frame on the scan to create block {number}",
|
||||||
"transcription_draw_tooltip": "Click and drag to mark a text region",
|
"transcription_draw_tooltip": "Click and drag to mark a text region",
|
||||||
"transcription_quote_stale": "Quote from an older version",
|
"transcription_quote_stale": "Quote from an older version",
|
||||||
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
||||||
@@ -511,7 +519,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.",
|
||||||
@@ -524,6 +531,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",
|
||||||
@@ -533,6 +541,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.",
|
||||||
@@ -802,5 +813,98 @@
|
|||||||
"chronik_load_more": "Load more",
|
"chronik_load_more": "Load more",
|
||||||
"chronik_loading": "Loading …",
|
"chronik_loading": "Loading …",
|
||||||
"chronik_load_more_announcement": "{count} more entries loaded",
|
"chronik_load_more_announcement": "{count} more entries loaded",
|
||||||
"chronik_view_all": "All activity →"
|
"chronik_view_all": "All activity →",
|
||||||
|
"pagination_prev": "Previous",
|
||||||
|
"pagination_next": "Next",
|
||||||
|
"pagination_page_of": "Page {page} of {total}",
|
||||||
|
"pagination_nav_label": "Pagination",
|
||||||
|
|
||||||
|
"common_opens_new_tab": "(opens in new tab)",
|
||||||
|
|
||||||
|
"transcribe_coach_title": "First transcription?",
|
||||||
|
"transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:",
|
||||||
|
"transcribe_coach_step_1_title": "Draw a frame.",
|
||||||
|
"transcribe_coach_step_1_body": "Click and drag a frame around the text you want to transcribe.",
|
||||||
|
"transcribe_coach_step_2_title": "Enter the text.",
|
||||||
|
"transcribe_coach_step_2_body": "Type the text you see inside the frame into the new text field.",
|
||||||
|
"transcribe_coach_step_3_title": "Saves automatically.",
|
||||||
|
"transcribe_coach_footer_kurrent": "Kurrent help ↗",
|
||||||
|
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
|
||||||
|
|
||||||
|
"transcription_mode_help_label": "Read and edit mode",
|
||||||
|
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||||
|
|
||||||
|
"richtlinien_title": "Transcription Guidelines",
|
||||||
|
"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": "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_rules_label": "Transcription rules",
|
||||||
|
"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_durchgestrichen_title": "Struck-through words",
|
||||||
|
"richtlinien_rule_durchgestrichen_body": "Struck-through text still belongs to the letter. Write it in square brackets with prefix durchgestrichen:",
|
||||||
|
"richtlinien_rule_langes_s_title": "The long s (ſ)",
|
||||||
|
"richtlinien_rule_langes_s_body": "The ſ is just an old written form of the letter s — not a separate sound. Always write a normal s.",
|
||||||
|
"richtlinien_rule_name_title": "Uncertain names",
|
||||||
|
"richtlinien_rule_name_body": "If you think you can read a name but aren't sure, add a question mark in square brackets.",
|
||||||
|
"richtlinien_rule_dialekt_title": "Dialect, foreign words, foreign quotes",
|
||||||
|
"richtlinien_rule_dialekt_body": "Low German, French, Latin phrases — copy them verbatim, exactly as written.",
|
||||||
|
"richtlinien_beispiel_label": "Example",
|
||||||
|
"richtlinien_klaerung_label": "Still to be decided",
|
||||||
|
"richtlinien_klaerung_intro": "These questions are still open — if you hit one while transcribing, make a plausible choice and note it in the comments:",
|
||||||
|
"richtlinien_klaer_abkuerzungen": "Abbreviations",
|
||||||
|
"richtlinien_klaer_datumsformate": "Date formats",
|
||||||
|
"richtlinien_klaer_umbrueche": "Original line breaks",
|
||||||
|
"richtlinien_klaer_caps": "Old capitalisation",
|
||||||
|
"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.",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -165,6 +169,10 @@
|
|||||||
"conv_hero_divider": "o",
|
"conv_hero_divider": "o",
|
||||||
"conv_empty_recent_label": "Recientemente abiertos",
|
"conv_empty_recent_label": "Recientemente abiertos",
|
||||||
"conv_no_party": "—",
|
"conv_no_party": "—",
|
||||||
|
"dist_bar_segment": "{count} de {name}",
|
||||||
|
"dist_bar_aria": "Distribución de cartas en este período: {outCount} de {senderName}, {inCount} de {receiverName}",
|
||||||
|
"row_direction_sent": "Enviada",
|
||||||
|
"row_direction_received": "Recibida",
|
||||||
"admin_heading": "Panel de administración",
|
"admin_heading": "Panel de administración",
|
||||||
"admin_tab_users": "Usuarios",
|
"admin_tab_users": "Usuarios",
|
||||||
"admin_tab_groups": "Grupos",
|
"admin_tab_groups": "Grupos",
|
||||||
@@ -495,7 +503,7 @@
|
|||||||
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
|
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
|
||||||
"transcription_block_history_btn": "Historial",
|
"transcription_block_history_btn": "Historial",
|
||||||
"transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir",
|
"transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir",
|
||||||
"transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}",
|
"transcription_next_block_cta": "Dibuje un marco en el escáner para crear el bloque {number}",
|
||||||
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
|
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
|
||||||
"transcription_quote_stale": "Cita de una versión anterior",
|
"transcription_quote_stale": "Cita de una versión anterior",
|
||||||
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
||||||
@@ -511,7 +519,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.",
|
||||||
@@ -524,6 +531,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",
|
||||||
@@ -533,6 +541,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.",
|
||||||
@@ -802,5 +813,98 @@
|
|||||||
"chronik_load_more": "Cargar más",
|
"chronik_load_more": "Cargar más",
|
||||||
"chronik_loading": "Cargando …",
|
"chronik_loading": "Cargando …",
|
||||||
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
||||||
"chronik_view_all": "Todas las actividades →"
|
"chronik_view_all": "Todas las actividades →",
|
||||||
|
"pagination_prev": "Anterior",
|
||||||
|
"pagination_next": "Siguiente",
|
||||||
|
"pagination_page_of": "Página {page} de {total}",
|
||||||
|
"pagination_nav_label": "Paginación",
|
||||||
|
|
||||||
|
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||||
|
|
||||||
|
"transcribe_coach_title": "¿Primera transcripción?",
|
||||||
|
"transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:",
|
||||||
|
"transcribe_coach_step_1_title": "Dibujar un marco.",
|
||||||
|
"transcribe_coach_step_1_body": "Haga clic y arrastre un marco alrededor del texto que desea transcribir.",
|
||||||
|
"transcribe_coach_step_2_title": "Ingresar el texto.",
|
||||||
|
"transcribe_coach_step_2_body": "Escriba el texto que ve dentro del marco en el nuevo campo de texto.",
|
||||||
|
"transcribe_coach_step_3_title": "Se guarda automáticamente.",
|
||||||
|
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
|
||||||
|
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
|
||||||
|
|
||||||
|
"transcription_mode_help_label": "Modo lectura y edición",
|
||||||
|
"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_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": "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_rules_label": "Reglas de transcripción",
|
||||||
|
"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_durchgestrichen_title": "Palabras tachadas",
|
||||||
|
"richtlinien_rule_durchgestrichen_body": "El texto tachado también pertenece a la carta. Escríbelo entre corchetes con el prefijo durchgestrichen:",
|
||||||
|
"richtlinien_rule_langes_s_title": "La s larga (ſ)",
|
||||||
|
"richtlinien_rule_langes_s_body": "La ſ es solo una forma antigua de la letra s. Escribe siempre una s normal.",
|
||||||
|
"richtlinien_rule_name_title": "Nombres inciertos",
|
||||||
|
"richtlinien_rule_name_body": "Si crees reconocer un nombre pero no estás seguro, añade un signo de interrogación entre corchetes.",
|
||||||
|
"richtlinien_rule_dialekt_title": "Dialecto, palabras extranjeras, citas",
|
||||||
|
"richtlinien_rule_dialekt_body": "Bajo alemán, francés, frases latinas — cópialas tal cual están escritas.",
|
||||||
|
"richtlinien_beispiel_label": "Ejemplo",
|
||||||
|
"richtlinien_klaerung_label": "Aún por decidir",
|
||||||
|
"richtlinien_klaerung_intro": "Estas preguntas aún están abiertas — si encuentras alguna mientras transcribes, elige algo razonable y nótalo en los comentarios:",
|
||||||
|
"richtlinien_klaer_abkuerzungen": "Abreviaturas",
|
||||||
|
"richtlinien_klaer_datumsformate": "Formatos de fecha",
|
||||||
|
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
||||||
|
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
||||||
|
"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.",
|
||||||
|
"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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default defineConfig({
|
|||||||
use: {
|
use: {
|
||||||
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
|
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
|
||||||
locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German
|
locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German
|
||||||
|
reducedMotion: 'reduce', // prevents SMIL/CSS animations from flaking tests
|
||||||
screenshot: 'on', // always capture screenshots
|
screenshot: 'on', // always capture screenshots
|
||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
trace: 'retain-on-failure'
|
trace: 'retain-on-failure'
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
45
frontend/src/lib/components/ConversationThumbnail.svelte
Normal file
45
frontend/src/lib/components/ConversationThumbnail.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type Doc = Pick<
|
||||||
|
components['schemas']['Document'],
|
||||||
|
'id' | 'thumbnailUrl' | 'thumbnailAspect' | 'pageCount'
|
||||||
|
>;
|
||||||
|
|
||||||
|
let { doc }: { doc: Doc } = $props();
|
||||||
|
|
||||||
|
const url = $derived(doc.thumbnailUrl ?? null);
|
||||||
|
const aspect = $derived(doc.thumbnailAspect ?? 'PORTRAIT');
|
||||||
|
const pageCount = $derived(doc.pageCount ?? 1);
|
||||||
|
const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[168px] w-[120px]');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="conv-thumb-tile"
|
||||||
|
data-aspect={aspect}
|
||||||
|
class="relative {tileClass} flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
|
||||||
|
>
|
||||||
|
{#if url}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="conv-thumb-skeleton"
|
||||||
|
class="h-full w-full bg-line/60 motion-safe:animate-pulse"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pageCount > 1}
|
||||||
|
<span
|
||||||
|
data-testid="conv-thumb-page-badge"
|
||||||
|
class="absolute top-1 right-1 rounded-full bg-primary/90 px-2 py-1 text-sm leading-none font-bold text-surface"
|
||||||
|
>{pageCount}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
112
frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts
Normal file
112
frontend/src/lib/components/ConversationThumbnail.svelte.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
|
||||||
|
import ConversationThumbnail from './ConversationThumbnail.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConversationThumbnail', () => {
|
||||||
|
it('renders the thumbnail image with a cache-busting v= query param', () => {
|
||||||
|
render(ConversationThumbnail, {
|
||||||
|
doc: {
|
||||||
|
id: '1111',
|
||||||
|
thumbnailUrl: '/api/documents/1111/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
|
thumbnailAspect: 'PORTRAIT',
|
||||||
|
pageCount: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const img = document.querySelector('img') as HTMLImageElement | null;
|
||||||
|
expect(img).not.toBeNull();
|
||||||
|
expect(img!.getAttribute('src')).toContain('/api/documents/1111/thumbnail');
|
||||||
|
expect(img!.getAttribute('src')).toContain('v=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses portrait dimensions when aspect is PORTRAIT', () => {
|
||||||
|
render(ConversationThumbnail, {
|
||||||
|
doc: {
|
||||||
|
id: 'p1',
|
||||||
|
thumbnailUrl: '/api/documents/p1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
|
thumbnailAspect: 'PORTRAIT',
|
||||||
|
pageCount: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement;
|
||||||
|
expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses landscape dimensions when aspect is LANDSCAPE', () => {
|
||||||
|
render(ConversationThumbnail, {
|
||||||
|
doc: {
|
||||||
|
id: 'l1',
|
||||||
|
thumbnailUrl: '/api/documents/l1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
|
thumbnailAspect: 'LANDSCAPE',
|
||||||
|
pageCount: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement;
|
||||||
|
expect(tile.getAttribute('data-aspect')).toBe('LANDSCAPE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PORTRAIT when thumbnailAspect is missing', () => {
|
||||||
|
render(ConversationThumbnail, {
|
||||||
|
doc: {
|
||||||
|
id: 'n1',
|
||||||
|
thumbnailUrl: '/api/documents/n1/thumbnail?v=2026-04-10T09%3A00%3A00Z'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement;
|
||||||
|
expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the page badge when pageCount is greater than 1', () => {
|
||||||
|
render(ConversationThumbnail, {
|
||||||
|
doc: {
|
||||||
|
id: 'm1',
|
||||||
|
thumbnailUrl: '/api/documents/m1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
|
thumbnailAspect: 'PORTRAIT',
|
||||||
|
pageCount: 4
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]') as HTMLElement;
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge.textContent).toContain('4');
|
||||||
|
// Senior-readable size: text-sm (14px) rather than text-xs (12px) on a
|
||||||
|
// small tile avoids marginal legibility on a 320px phone.
|
||||||
|
expect(badge.className).toContain('text-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the page badge when pageCount is 1 or missing', () => {
|
||||||
|
render(ConversationThumbnail, {
|
||||||
|
doc: {
|
||||||
|
id: 's1',
|
||||||
|
thumbnailUrl: '/api/documents/s1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
|
thumbnailAspect: 'PORTRAIT',
|
||||||
|
pageCount: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]');
|
||||||
|
expect(badge).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a skeleton placeholder when no thumbnailUrl is set yet', () => {
|
||||||
|
render(ConversationThumbnail, {
|
||||||
|
doc: {
|
||||||
|
id: 'blank',
|
||||||
|
thumbnailAspect: 'PORTRAIT'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.querySelector('img')).toBeNull();
|
||||||
|
const skeleton = document.querySelector('[data-testid="conv-thumb-skeleton"]');
|
||||||
|
expect(skeleton).not.toBeNull();
|
||||||
|
expect(skeleton!.className).toContain('motion-safe:animate-pulse');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,27 +44,41 @@ function safeColor(color: string): string {
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
|
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
|
||||||
<svg
|
<div
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
class="relative h-[252px] w-[180px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
|
||||||
width="180"
|
|
||||||
height="246"
|
|
||||||
viewBox="0 0 180 246"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="shrink-0"
|
|
||||||
>
|
>
|
||||||
<defs>
|
{#if resumeDoc.thumbnailUrl}
|
||||||
<linearGradient id="parchment" x1="0" y1="0" x2="0" y2="1">
|
<img
|
||||||
<stop offset="0%" stop-color="#f5f0e8" />
|
data-testid="resume-thumbnail-img"
|
||||||
<stop offset="100%" stop-color="#ede8d5" />
|
src={resumeDoc.thumbnailUrl}
|
||||||
</linearGradient>
|
alt=""
|
||||||
</defs>
|
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
|
||||||
<rect width="180" height="246" fill="url(#parchment)" />
|
loading="lazy"
|
||||||
<line x1="30" y1="40" x2="150" y2="40" stroke="#b0a898" stroke-width="1" />
|
decoding="async"
|
||||||
<line x1="30" y1="70" x2="150" y2="70" stroke="#b0a898" stroke-width="1" />
|
/>
|
||||||
<line x1="30" y1="100" x2="150" y2="100" stroke="#b0a898" stroke-width="1" />
|
{:else}
|
||||||
<line x1="30" y1="130" x2="150" y2="130" stroke="#b0a898" stroke-width="1" />
|
<div
|
||||||
<line x1="30" y1="160" x2="150" y2="160" stroke="#b0a898" stroke-width="1" />
|
data-testid="resume-thumbnail-fallback"
|
||||||
</svg>
|
class="flex h-full w-full items-center justify-center text-ink-3"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="h-24 w-24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z M9 12.75h6M9 15.75h6M9 18.75h3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-2">
|
<div class="flex flex-1 flex-col gap-2">
|
||||||
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">
|
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ const mockResume: DashboardResumeDTO = {
|
|||||||
collaborators: []
|
collaborators: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockResumeWithThumbnail: DashboardResumeDTO = {
|
||||||
|
...mockResume,
|
||||||
|
thumbnailUrl: '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00'
|
||||||
|
};
|
||||||
|
|
||||||
describe('DashboardResumeStrip', () => {
|
describe('DashboardResumeStrip', () => {
|
||||||
it('renders empty state heading when resumeDoc is null', async () => {
|
it('renders empty state heading when resumeDoc is null', async () => {
|
||||||
render(DashboardResumeStrip, { resumeDoc: null });
|
render(DashboardResumeStrip, { resumeDoc: null });
|
||||||
@@ -52,4 +57,23 @@ describe('DashboardResumeStrip', () => {
|
|||||||
const label = page.getByText(/4 Abschnitte/i);
|
const label = page.getByText(/4 Abschnitte/i);
|
||||||
await expect.element(label).toBeInTheDocument();
|
await expect.element(label).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders thumbnail img with expected attrs when thumbnailUrl is set', async () => {
|
||||||
|
render(DashboardResumeStrip, { resumeDoc: mockResumeWithThumbnail });
|
||||||
|
const img = page.getByTestId('resume-thumbnail-img');
|
||||||
|
await expect.element(img).toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(img)
|
||||||
|
.toHaveAttribute('src', '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00');
|
||||||
|
await expect.element(img).toHaveAttribute('alt', '');
|
||||||
|
await expect.element(img).toHaveAttribute('loading', 'lazy');
|
||||||
|
await expect.element(img).toHaveAttribute('decoding', 'async');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fallback icon when thumbnailUrl is null', async () => {
|
||||||
|
render(DashboardResumeStrip, { resumeDoc: mockResume });
|
||||||
|
const fallback = page.getByTestId('resume-thumbnail-fallback');
|
||||||
|
await expect.element(fallback).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByTestId('resume-thumbnail-img')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
60
frontend/src/lib/components/DistributionBar.svelte
Normal file
60
frontend/src/lib/components/DistributionBar.svelte
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
outCount: number;
|
||||||
|
inCount: number;
|
||||||
|
senderName: string;
|
||||||
|
receiverName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { outCount, inCount, senderName, receiverName }: Props = $props();
|
||||||
|
|
||||||
|
const total = $derived(outCount + inCount);
|
||||||
|
const outPct = $derived(total > 0 ? (outCount / total) * 100 : 0);
|
||||||
|
const shortSenderName = $derived(senderName.split(' ')[0] ?? senderName);
|
||||||
|
const shortReceiverName = $derived(receiverName.split(' ')[0] ?? receiverName);
|
||||||
|
|
||||||
|
const ariaLabel = $derived(m.dist_bar_aria({ outCount, senderName, inCount, receiverName }));
|
||||||
|
const outSegmentText = $derived(m.dist_bar_segment({ count: outCount, name: shortSenderName }));
|
||||||
|
const inSegmentText = $derived(m.dist_bar_segment({ count: inCount, name: shortReceiverName }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<div class="flex justify-between text-sm font-bold">
|
||||||
|
<span class="inline-flex items-center gap-1 text-primary"
|
||||||
|
>{outSegmentText}
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="inline h-3.5 w-3.5 opacity-60"
|
||||||
|
/></span
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-1 text-accent"
|
||||||
|
>{inSegmentText}
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="inline h-3.5 w-3.5 opacity-60"
|
||||||
|
/></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
|
||||||
|
<div
|
||||||
|
data-testid="dist-bar-segment"
|
||||||
|
class="h-full bg-primary transition-all"
|
||||||
|
style="width: {outPct}%"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
data-testid="dist-bar-segment"
|
||||||
|
class="h-full bg-accent transition-all"
|
||||||
|
style="width: {100 - outPct}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
71
frontend/src/lib/components/DistributionBar.svelte.spec.ts
Normal file
71
frontend/src/lib/components/DistributionBar.svelte.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
import DistributionBar from './DistributionBar.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DistributionBar', () => {
|
||||||
|
it('renders the Paraglide aria-label and visible segments', async () => {
|
||||||
|
render(DistributionBar, {
|
||||||
|
outCount: 3,
|
||||||
|
inCount: 7,
|
||||||
|
senderName: 'Hans Müller',
|
||||||
|
receiverName: 'Anna Schmidt'
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = document.querySelector('[role="img"]') as HTMLElement;
|
||||||
|
expect(container).toBeTruthy();
|
||||||
|
|
||||||
|
// The aria-label must come from Paraglide, not a hardcoded German string,
|
||||||
|
// so the EN / ES users aren't served "Briefverteilung in diesem Zeitraum".
|
||||||
|
const expectedAria = m.dist_bar_aria({
|
||||||
|
outCount: 3,
|
||||||
|
senderName: 'Hans Müller',
|
||||||
|
inCount: 7,
|
||||||
|
receiverName: 'Anna Schmidt'
|
||||||
|
});
|
||||||
|
expect(container.getAttribute('aria-label')).toBe(expectedAria);
|
||||||
|
|
||||||
|
// The visible "{count} from/von {name}" spans must also come from Paraglide.
|
||||||
|
const outText = m.dist_bar_segment({ count: 3, name: 'Hans' });
|
||||||
|
const inText = m.dist_bar_segment({ count: 7, name: 'Anna' });
|
||||||
|
expect(container.textContent).toContain(outText);
|
||||||
|
expect(container.textContent).toContain(inText);
|
||||||
|
|
||||||
|
// 3/10 → 30% / 70% split on the two segments
|
||||||
|
const segments = container.querySelectorAll('[data-testid="dist-bar-segment"]');
|
||||||
|
expect(segments).toHaveLength(2);
|
||||||
|
expect((segments[0] as HTMLElement).style.width).toBe('30%');
|
||||||
|
expect((segments[1] as HTMLElement).style.width).toBe('70%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the full name when it has no space to split', async () => {
|
||||||
|
render(DistributionBar, {
|
||||||
|
outCount: 1,
|
||||||
|
inCount: 0,
|
||||||
|
senderName: 'SingleWord',
|
||||||
|
receiverName: 'Another'
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = document.querySelector('[role="img"]') as HTMLElement;
|
||||||
|
const expected = m.dist_bar_segment({ count: 1, name: 'SingleWord' });
|
||||||
|
expect(container.textContent).toContain(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a zero-percent left segment when outCount is zero', async () => {
|
||||||
|
render(DistributionBar, {
|
||||||
|
outCount: 0,
|
||||||
|
inCount: 4,
|
||||||
|
senderName: 'Hans',
|
||||||
|
receiverName: 'Anna'
|
||||||
|
});
|
||||||
|
|
||||||
|
const segments = document.querySelectorAll('[data-testid="dist-bar-segment"]');
|
||||||
|
expect((segments[0] as HTMLElement).style.width).toBe('0%');
|
||||||
|
expect((segments[1] as HTMLElement).style.width).toBe('100%');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api';
|
|||||||
import { applyOffsets } from '$lib/search';
|
import { applyOffsets } from '$lib/search';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||||
import ProgressRing from './ProgressRing.svelte';
|
import ProgressRing from './ProgressRing.svelte';
|
||||||
import ContributorStack from './ContributorStack.svelte';
|
import ContributorStack from './ContributorStack.svelte';
|
||||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||||
|
|
||||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
let { item }: { item: DocumentSearchItem } = $props();
|
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
|
||||||
|
|
||||||
const doc = $derived(item.document);
|
const doc = $derived(item.document);
|
||||||
const titleText = $derived(doc.title || doc.originalFilename);
|
const titleText = $derived(doc.title || doc.originalFilename);
|
||||||
@@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {
|
|||||||
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
|
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
|
||||||
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
|
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
|
||||||
<div class="flex gap-3 sm:gap-5">
|
<div class="flex gap-3 sm:gap-5">
|
||||||
|
<!-- Bulk-selection checkbox -->
|
||||||
|
{#if canWrite}
|
||||||
|
<label
|
||||||
|
class="pointer-events-auto flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-start pt-1"
|
||||||
|
data-testid="bulk-select-checkbox"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5 cursor-pointer accent-brand-navy"
|
||||||
|
checked={bulkSelectionStore.has(doc.id)}
|
||||||
|
onchange={() => bulkSelectionStore.toggle(doc.id)}
|
||||||
|
aria-label={m.bulk_edit_select_document({ title: titleText })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
<!-- Thumbnail tile -->
|
<!-- Thumbnail tile -->
|
||||||
<DocumentThumbnail doc={doc} size="lg" />
|
<DocumentThumbnail doc={doc} size="lg" />
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import DocumentRow from './DocumentRow.svelte';
|
import DocumentRow from './DocumentRow.svelte';
|
||||||
|
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
@@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.mocked(goto).mockClear();
|
vi.mocked(goto).mockClear();
|
||||||
|
bulkSelectionStore.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
@@ -265,6 +267,45 @@ describe('DocumentRow – tags', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bulk-selection checkbox ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DocumentRow – bulk selection checkbox', () => {
|
||||||
|
it('does not render the checkbox when canWrite is false', async () => {
|
||||||
|
render(DocumentRow, { item: makeItem(), canWrite: false });
|
||||||
|
await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the checkbox when canWrite is true', async () => {
|
||||||
|
render(DocumentRow, { item: makeItem(), canWrite: true });
|
||||||
|
await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkbox aria-label includes the document title', async () => {
|
||||||
|
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
|
||||||
|
render(DocumentRow, { item, canWrite: true });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
|
||||||
|
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
|
||||||
|
render(DocumentRow, { item, canWrite: true });
|
||||||
|
expect(bulkSelectionStore.has('doc-42')).toBe(false);
|
||||||
|
|
||||||
|
document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.click();
|
||||||
|
|
||||||
|
await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checked state mirrors the store', async () => {
|
||||||
|
bulkSelectionStore.add('doc-99');
|
||||||
|
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
|
||||||
|
render(DocumentRow, { item, canWrite: true });
|
||||||
|
await expect.element(page.getByRole('checkbox')).toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
||||||
|
|
||||||
describe('DocumentRow – progress ring and contributors', () => {
|
describe('DocumentRow – progress ring and contributors', () => {
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { thumbnailUrl } from '$lib/thumbnails';
|
|
||||||
|
|
||||||
type Doc = Pick<
|
type Doc = Pick<components['schemas']['Document'], 'id' | 'thumbnailUrl' | 'contentType'>;
|
||||||
components['schemas']['Document'],
|
|
||||||
'id' | 'thumbnailKey' | 'thumbnailGeneratedAt' | 'contentType'
|
|
||||||
>;
|
|
||||||
|
|
||||||
let { doc, size = 'sm' }: { doc: Doc; size?: 'sm' | 'lg' } = $props();
|
let { doc, size = 'sm' }: { doc: Doc; size?: 'sm' | 'lg' } = $props();
|
||||||
const url = $derived(thumbnailUrl(doc));
|
const url = $derived(doc.thumbnailUrl ?? null);
|
||||||
|
|
||||||
const containerClass = $derived(
|
const containerClass = $derived(
|
||||||
size === 'lg'
|
size === 'lg'
|
||||||
|
|||||||
101
frontend/src/lib/components/HelpPopover.svelte
Normal file
101
frontend/src/lib/components/HelpPopover.svelte
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script module>
|
||||||
|
// Module-level counter produces stable, predictable IDs across SSR + hydration.
|
||||||
|
// Math.random() would generate different values server-side vs client-side,
|
||||||
|
// causing a hydration mismatch on first render.
|
||||||
|
let _counter = 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Placement = 'bottom' | 'top' | 'left' | 'right';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
placement?: Placement;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { label, placement = 'bottom', children }: Props = $props();
|
||||||
|
|
||||||
|
const popoverId = `help-popover-${_counter++}`;
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let triggerEl: HTMLButtonElement | null = $state(null);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open = !open;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
triggerEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (triggerEl && (e.target === triggerEl || triggerEl.contains(e.target as Node))) return;
|
||||||
|
const popoverEl = document.getElementById(popoverId);
|
||||||
|
if (popoverEl && popoverEl.contains(e.target as Node)) return;
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const placementClass: Record<Placement, string> = {
|
||||||
|
bottom: 'top-full mt-1.5 left-1/2 -translate-x-1/2',
|
||||||
|
top: 'bottom-full mb-1.5 left-1/2 -translate-x-1/2',
|
||||||
|
left: 'right-full mr-1.5 top-1/2 -translate-y-1/2',
|
||||||
|
right: 'left-full ml-1.5 top-1/2 -translate-y-1/2'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<!--
|
||||||
|
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
|
||||||
|
audience is 60+). The inner <span> carries the visual 20×20px circle.
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
bind:this={triggerEl}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={popoverId}
|
||||||
|
onclick={toggle}
|
||||||
|
class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors group-hover:border-brand-navy group-hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
id={popoverId}
|
||||||
|
role="region"
|
||||||
|
aria-label={label}
|
||||||
|
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
114
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
114
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import HelpPopover from './HelpPopover.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderPopover(label = 'Help') {
|
||||||
|
return render(HelpPopover, { props: { label } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HelpPopover — initial state', () => {
|
||||||
|
it('renders a trigger button with the given label', async () => {
|
||||||
|
renderPopover();
|
||||||
|
const btn = page.getByRole('button', { name: /Help/ });
|
||||||
|
await expect.element(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts closed: aria-expanded is false, popover not in DOM', async () => {
|
||||||
|
renderPopover();
|
||||||
|
const btn = page.getByRole('button', { name: /Help/ });
|
||||||
|
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(document.querySelector('[role="region"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HelpPopover — open / close interactions', () => {
|
||||||
|
it('opens on click: aria-expanded true, popover in DOM', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
const btn = page.getByRole('button', { name: /Help/ });
|
||||||
|
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on Esc key', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on outside click', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||||
|
|
||||||
|
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens on Enter key', async () => {
|
||||||
|
renderPopover();
|
||||||
|
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens on Space key', async () => {
|
||||||
|
renderPopover();
|
||||||
|
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||||
|
await userEvent.keyboard('{Space}');
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HelpPopover — hover-target', () => {
|
||||||
|
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
|
||||||
|
const { container } = renderPopover();
|
||||||
|
const btn = container.querySelector('button[aria-expanded]')!;
|
||||||
|
const span = btn.querySelector('span')!;
|
||||||
|
const btnClasses = btn.className.split(/\s+/);
|
||||||
|
const spanClasses = span.className.split(/\s+/);
|
||||||
|
expect(btnClasses).toContain('group');
|
||||||
|
expect(spanClasses).not.toContain('hover:border-brand-navy');
|
||||||
|
expect(spanClasses).toContain('group-hover:border-brand-navy');
|
||||||
|
expect(spanClasses).not.toContain('hover:text-brand-navy');
|
||||||
|
expect(spanClasses).toContain('group-hover:text-brand-navy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outer button has focus-visible ring for keyboard users', () => {
|
||||||
|
const { container } = renderPopover();
|
||||||
|
const btn = container.querySelector('button[aria-expanded]')!;
|
||||||
|
expect(btn.className).toContain('focus-visible:ring-2');
|
||||||
|
expect(btn.className).toContain('focus-visible:ring-brand-navy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HelpPopover — aria wiring', () => {
|
||||||
|
it('trigger aria-controls matches popover element id', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
const btn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||||
|
const controls = btn.getAttribute('aria-controls');
|
||||||
|
expect(controls).toBeTruthy();
|
||||||
|
const popover = document.getElementById(controls!);
|
||||||
|
expect(popover).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
|
||||||
|
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
|
||||||
|
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
|
||||||
|
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||||
|
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||||
|
expect(id1).toBeTruthy();
|
||||||
|
expect(id2).toBeTruthy();
|
||||||
|
expect(id1).not.toBe(id2);
|
||||||
|
// IDs must be deterministic (counter-based), not random hex
|
||||||
|
expect(id1).toMatch(/^help-popover-\d+$/);
|
||||||
|
expect(id2).toMatch(/^help-popover-\d+$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,6 +48,12 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bellLabel = $derived(
|
||||||
|
stream.unreadCount > 0
|
||||||
|
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||||
|
: m.notification_bell_label()
|
||||||
|
);
|
||||||
|
|
||||||
function attachBellButton(node: HTMLButtonElement) {
|
function attachBellButton(node: HTMLButtonElement) {
|
||||||
bellButtonEl = node;
|
bellButtonEl = node;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -72,12 +78,11 @@ onDestroy(() => {
|
|||||||
{@attach attachBellButton}
|
{@attach attachBellButton}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleDropdown}
|
onclick={toggleDropdown}
|
||||||
aria-label={stream.unreadCount > 0
|
aria-label={bellLabel}
|
||||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
title={bellLabel}
|
||||||
: m.notification_bell_label()}
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
|
|||||||
notifButton.click();
|
notifButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('NotificationBell — cursor and tooltip', () => {
|
||||||
|
it('bell button has cursor-pointer class', async () => {
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
||||||
|
mockNotificationList.value = [];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
||||||
|
mockNotificationList.value = [
|
||||||
|
makeNotification({ id: 'n1' }),
|
||||||
|
makeNotification({ id: 'n2' }),
|
||||||
|
makeNotification({ id: 'n3' })
|
||||||
|
];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('NotificationBell', () => {
|
describe('NotificationBell', () => {
|
||||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
||||||
|
|||||||
80
frontend/src/lib/components/Pagination.svelte
Normal file
80
frontend/src/lib/components/Pagination.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 0-indexed current page. */
|
||||||
|
page: number;
|
||||||
|
/** Total number of pages. `0` or `1` hides the control as trivially there's nothing to navigate. */
|
||||||
|
totalPages: number;
|
||||||
|
/** Given a 0-indexed page number, returns the href the link should point at. */
|
||||||
|
makeHref: (page: number) => string;
|
||||||
|
/** Optional override for the outer `<nav>`'s aria-label. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, totalPages, makeHref, ariaLabel }: Props = $props();
|
||||||
|
|
||||||
|
const hasPrev = $derived(page > 0);
|
||||||
|
const hasNext = $derived(page < totalPages - 1);
|
||||||
|
const controlBase =
|
||||||
|
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
|
||||||
|
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
|
||||||
|
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<nav
|
||||||
|
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
||||||
|
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
At the bounds we render a <span aria-hidden="true"> instead of an
|
||||||
|
<a aria-disabled>. aria-disabled on a link is the documented pattern
|
||||||
|
but screen readers still announce "Previous, link, disabled" — which
|
||||||
|
is confusing on a pagination control where the disabled state is
|
||||||
|
purely visual. Hiding the element from the AT tree entirely is the
|
||||||
|
cleaner semantic.
|
||||||
|
-->
|
||||||
|
{#if hasPrev}
|
||||||
|
<a
|
||||||
|
data-testid="pagination-prev"
|
||||||
|
aria-label={m.pagination_prev()}
|
||||||
|
href={makeHref(page - 1)}
|
||||||
|
class={linkBase}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
{m.pagination_prev()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span data-testid="pagination-prev" aria-hidden="true" class={disabledBase}>
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
{m.pagination_prev()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span
|
||||||
|
data-testid="pagination-page-label"
|
||||||
|
aria-current="page"
|
||||||
|
class="font-sans text-sm text-ink-2"
|
||||||
|
>
|
||||||
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if hasNext}
|
||||||
|
<a
|
||||||
|
data-testid="pagination-next"
|
||||||
|
aria-label={m.pagination_next()}
|
||||||
|
href={makeHref(page + 1)}
|
||||||
|
class={linkBase}
|
||||||
|
>
|
||||||
|
{m.pagination_next()}
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span data-testid="pagination-next" aria-hidden="true" class={disabledBase}>
|
||||||
|
{m.pagination_next()}
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
86
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
86
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import Pagination from './Pagination.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeHref = (p: number) => `/documents?page=${p}`;
|
||||||
|
|
||||||
|
describe('Pagination', () => {
|
||||||
|
it('renders the page-of-total label for the current page', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const label = page.getByTestId('pagination-page-label');
|
||||||
|
await expect.element(label).toHaveTextContent(/3/); // page is 0-indexed, label is 1-indexed
|
||||||
|
await expect.element(label).toHaveTextContent(/10/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the current page label with aria-current="page"', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const label = page.getByTestId('pagination-page-label');
|
||||||
|
await expect.element(label).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders disabled prev as an aria-hidden non-link so screen readers skip it', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
// Not a link — no href, no role=link
|
||||||
|
await expect.element(prev).not.toHaveAttribute('href');
|
||||||
|
// Hidden from assistive tech — AT shouldn't read "Previous, link, disabled"
|
||||||
|
await expect.element(prev).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const next = page.getByTestId('pagination-next');
|
||||||
|
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders disabled next as an aria-hidden non-link on the last page', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const next = page.getByTestId('pagination-next');
|
||||||
|
await expect.element(next).not.toHaveAttribute('href');
|
||||||
|
await expect.element(next).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls makeHref with p-1 and p+1', async () => {
|
||||||
|
const spy = vi.fn(makeHref);
|
||||||
|
render(Pagination, { page: 3, totalPages: 10, makeHref: spy });
|
||||||
|
|
||||||
|
const calls = spy.mock.calls.map((c) => c[0]).sort((a, b) => a - b);
|
||||||
|
expect(calls).toContain(2);
|
||||||
|
expect(calls).toContain(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders decorative chevron inside aria-hidden span so screen readers skip it', async () => {
|
||||||
|
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect
|
||||||
|
.element(prev.getByText('«', { exact: true }))
|
||||||
|
.toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prev and next have min 44px touch targets', async () => {
|
||||||
|
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const prev = page.getByTestId('pagination-prev');
|
||||||
|
await expect.element(prev).toHaveClass(/min-h-\[44px\]/);
|
||||||
|
await expect.element(prev).toHaveClass(/min-w-\[44px\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
|
|||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||||
>
|
>
|
||||||
{#each selectedPersons as person (person.id)}
|
{#each selectedPersons as person (person.id)}
|
||||||
<span
|
<span
|
||||||
|
|||||||
58
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
58
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { radioGroupNav } from '$lib/actions/radioGroupNav';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = 'PERSON',
|
||||||
|
name = 'personType',
|
||||||
|
onchange
|
||||||
|
}: { value?: string; name?: string; onchange?: (type: PersonType) => void } = $props();
|
||||||
|
|
||||||
|
let selected = $state<PersonType>(
|
||||||
|
untrack(() => (TYPES.includes(value as PersonType) ? (value as PersonType) : 'PERSON'))
|
||||||
|
);
|
||||||
|
|
||||||
|
let announcement = $state('');
|
||||||
|
|
||||||
|
const labels: Record<PersonType, () => string> = {
|
||||||
|
PERSON: m.person_type_PERSON,
|
||||||
|
INSTITUTION: m.person_type_INSTITUTION,
|
||||||
|
GROUP: m.person_type_GROUP,
|
||||||
|
UNKNOWN: m.person_type_UNKNOWN
|
||||||
|
};
|
||||||
|
|
||||||
|
function select(type: PersonType) {
|
||||||
|
selected = type;
|
||||||
|
announcement = m.a11y_type_changed({ type: labels[type]() });
|
||||||
|
onchange?.(type);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={m.form_label_person_type()}
|
||||||
|
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||||
|
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
|
||||||
|
>
|
||||||
|
{#each TYPES as type (type)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
value={type}
|
||||||
|
aria-checked={selected === type}
|
||||||
|
tabindex={selected === type ? 0 : -1}
|
||||||
|
onclick={() => select(type)}
|
||||||
|
class="min-h-[48px] cursor-pointer rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected === type
|
||||||
|
? 'border-primary bg-primary text-primary-fg'
|
||||||
|
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||||
|
>
|
||||||
|
{labels[type]()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name={name} value={selected} />
|
||||||
|
|
||||||
|
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { userEvent } from 'vitest/browser';
|
||||||
|
|
||||||
|
import PersonTypeSelector from './PersonTypeSelector.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('PersonTypeSelector', () => {
|
||||||
|
it('radiogroup has an accessible name via aria-label', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const radiogroup = container.querySelector('[role="radiogroup"]');
|
||||||
|
expect(radiogroup).not.toBeNull();
|
||||||
|
expect(radiogroup!.getAttribute('aria-label')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden input value updates when user navigates with ArrowRight', async () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||||
|
expect(hiddenInput.value).toBe('PERSON');
|
||||||
|
|
||||||
|
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||||
|
personButton.focus();
|
||||||
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
|
|
||||||
|
expect(hiddenInput.value).toBe('INSTITUTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||||
|
expect(hiddenInput.value).toBe('PERSON');
|
||||||
|
|
||||||
|
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||||
|
personButton.focus();
|
||||||
|
await userEvent.keyboard('{ArrowLeft}');
|
||||||
|
|
||||||
|
expect(hiddenInput.value).toBe('UNKNOWN');
|
||||||
|
});
|
||||||
|
it('exactly one button is aria-checked=true for the initial value', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'INSTITUTION' });
|
||||||
|
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||||
|
const checked = buttons.filter((b) => b.getAttribute('aria-checked') === 'true');
|
||||||
|
const unchecked = buttons.filter((b) => b.getAttribute('aria-checked') === 'false');
|
||||||
|
expect(checked).toHaveLength(1);
|
||||||
|
expect(unchecked).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-checked=true moves to clicked button on click', async () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||||
|
const groupButton = buttons.find((b) => b.getAttribute('value') === 'GROUP') as HTMLElement;
|
||||||
|
await userEvent.click(groupButton);
|
||||||
|
expect(groupButton.getAttribute('aria-checked')).toBe('true');
|
||||||
|
const others = buttons.filter((b) => b !== groupButton);
|
||||||
|
for (const btn of others) {
|
||||||
|
expect(btn.getAttribute('aria-checked')).toBe('false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||||
|
const selected = buttons.find((b) => b.getAttribute('aria-checked') === 'true');
|
||||||
|
const unselected = buttons.filter((b) => b.getAttribute('aria-checked') !== 'true');
|
||||||
|
expect(selected!.getAttribute('tabindex')).toBe('0');
|
||||||
|
for (const btn of unselected) {
|
||||||
|
expect(btn.getAttribute('tabindex')).toBe('-1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api';
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||||
|
import FieldLabelBadge from './document/FieldLabelBadge.svelte';
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
restrictToCorrespondentsOf?: string;
|
restrictToCorrespondentsOf?: string;
|
||||||
|
badge?: 'additive' | 'replace';
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
onfocused?: () => void;
|
onfocused?: () => void;
|
||||||
}
|
}
|
||||||
@@ -34,6 +36,7 @@ let {
|
|||||||
autofocus = false,
|
autofocus = false,
|
||||||
required = false,
|
required = false,
|
||||||
restrictToCorrespondentsOf,
|
restrictToCorrespondentsOf,
|
||||||
|
badge,
|
||||||
onchange,
|
onchange,
|
||||||
onfocused
|
onfocused
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -116,7 +119,7 @@ function selectPerson(person: Person) {
|
|||||||
class={compact
|
class={compact
|
||||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||||
: 'block text-sm font-medium text-ink-2'}
|
: 'block text-sm font-medium text-ink-2'}
|
||||||
>{label}{#if required}*{/if}</label
|
>{label}{#if required}*{/if}{#if badge}<FieldLabelBadge variant={badge} />{/if}</label
|
||||||
>
|
>
|
||||||
|
|
||||||
<input type="hidden" name={name} bind:value={value} />
|
<input type="hidden" name={name} bind:value={value} />
|
||||||
@@ -134,7 +137,7 @@ function selectPerson(person: Person) {
|
|||||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
: compact
|
: compact
|
||||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||||
|
|||||||
46
frontend/src/lib/components/RichtlinienRuleCard.svelte
Normal file
46
frontend/src/lib/components/RichtlinienRuleCard.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
beispielInput?: string;
|
||||||
|
beispielInputStrike?: boolean;
|
||||||
|
beispielOutput?: string;
|
||||||
|
beispielLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
beispielInput,
|
||||||
|
beispielInputStrike = false,
|
||||||
|
beispielOutput,
|
||||||
|
beispielLabel = 'Beispiel'
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<span aria-hidden="true" class="text-xl">{icon}</span>
|
||||||
|
<h3 class="font-serif text-base font-bold text-ink">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
|
||||||
|
|
||||||
|
{#if beispielOutput !== undefined}
|
||||||
|
<div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
|
||||||
|
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
|
||||||
|
{beispielLabel}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 font-sans text-sm text-ink">
|
||||||
|
{#if beispielInput !== undefined}
|
||||||
|
<code
|
||||||
|
class={['font-mono', beispielInputStrike && 'line-through'].filter(Boolean).join(' ')}
|
||||||
|
>{beispielInput}</code
|
||||||
|
> →
|
||||||
|
{/if}
|
||||||
|
<code class="font-mono">{beispielOutput}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import RichtlinienRuleCard from './RichtlinienRuleCard.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
icon: '✍',
|
||||||
|
title: 'Unleserliche Wörter',
|
||||||
|
body: 'Schreiben Sie [unleserlich].',
|
||||||
|
beispielOutput: '[unleserlich]'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RichtlinienRuleCard', () => {
|
||||||
|
it('renders an h3 with the title', async () => {
|
||||||
|
render(RichtlinienRuleCard, { props: defaultProps });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('heading', { level: 3 }))
|
||||||
|
.toHaveTextContent('Unleserliche Wörter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the body text', async () => {
|
||||||
|
render(RichtlinienRuleCard, { props: defaultProps });
|
||||||
|
await expect.element(page.getByText('Schreiben Sie [unleserlich].')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icon in a span with aria-hidden="true"', async () => {
|
||||||
|
render(RichtlinienRuleCard, { props: defaultProps });
|
||||||
|
const iconSpan = document.querySelector('span[aria-hidden="true"]');
|
||||||
|
expect(iconSpan).not.toBeNull();
|
||||||
|
expect(iconSpan!.textContent).toContain('✍');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders beispielOutput in monospace with → arrow', async () => {
|
||||||
|
render(RichtlinienRuleCard, { props: defaultProps });
|
||||||
|
const mono = document.querySelector('code, [class*="font-mono"]');
|
||||||
|
expect(mono).not.toBeNull();
|
||||||
|
expect(mono!.textContent).toContain('[unleserlich]');
|
||||||
|
await expect.element(page.getByText(/→/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render beispiel section when beispielOutput is absent', async () => {
|
||||||
|
render(RichtlinienRuleCard, {
|
||||||
|
props: { icon: '✍', title: 'Test', body: 'Body' }
|
||||||
|
});
|
||||||
|
expect(document.querySelector('code, [class*="font-mono"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
frontend/src/lib/components/TagChipList.svelte
Normal file
23
frontend/src/lib/components/TagChipList.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Tag = { id: string; name: string };
|
||||||
|
|
||||||
|
let { tags, max = 3 }: { tags: Tag[]; max?: number } = $props();
|
||||||
|
|
||||||
|
const displayedTags = $derived(tags.slice(0, max));
|
||||||
|
const hiddenTagCount = $derived(Math.max(0, tags.length - max));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if tags.length > 0}
|
||||||
|
<div class="flex flex-wrap items-center gap-1 pt-0.5">
|
||||||
|
{#each displayedTags as tag (tag.id)}
|
||||||
|
<span
|
||||||
|
data-testid="thumb-row-tag"
|
||||||
|
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-sm text-ink-2"
|
||||||
|
>{tag.name}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#if hiddenTagCount > 0}
|
||||||
|
<span class="text-sm font-bold text-ink-3">+{hiddenTagCount}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
41
frontend/src/lib/components/TagChipList.svelte.spec.ts
Normal file
41
frontend/src/lib/components/TagChipList.svelte.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
|
||||||
|
import TagChipList from './TagChipList.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeTags = (n: number) =>
|
||||||
|
Array.from({ length: n }, (_, i) => ({ id: `t${i}`, name: `Tag${i}` }));
|
||||||
|
|
||||||
|
describe('TagChipList', () => {
|
||||||
|
it('renders all tags as chips when under the cap', () => {
|
||||||
|
render(TagChipList, { tags: makeTags(2), max: 3 });
|
||||||
|
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||||
|
expect(chips).toHaveLength(2);
|
||||||
|
expect(document.body.textContent).not.toMatch(/\+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps visible chips at max and renders +N for the remainder', () => {
|
||||||
|
render(TagChipList, { tags: makeTags(5), max: 3 });
|
||||||
|
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||||
|
expect(chips).toHaveLength(3);
|
||||||
|
expect(document.body.textContent).toMatch(/\+2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when tags is empty', () => {
|
||||||
|
render(TagChipList, { tags: [], max: 3 });
|
||||||
|
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||||
|
expect(chips).toHaveLength(0);
|
||||||
|
expect(document.body.textContent).not.toMatch(/\+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults max to 3 when the prop is omitted', () => {
|
||||||
|
render(TagChipList, { tags: makeTags(5) });
|
||||||
|
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||||
|
expect(chips).toHaveLength(3);
|
||||||
|
expect(document.body.textContent).toMatch(/\+2/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ onMount(() => {
|
|||||||
theme = resolveInitialTheme();
|
theme = resolveInitialTheme();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const themeLabel = $derived(
|
||||||
|
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
|
||||||
|
);
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
theme = theme === 'dark' ? 'light' : 'dark';
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
@@ -29,8 +34,8 @@ function toggle() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
aria-label={themeLabel}
|
||||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
title={themeLabel}
|
||||||
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{#if theme === 'dark'}
|
{#if theme === 'dark'}
|
||||||
|
|||||||
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import ThemeToggle from './ThemeToggle.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (light mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to dark mode when theme is light', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in light mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (dark mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to light mode when theme is dark', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in dark mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
98
frontend/src/lib/components/ThumbnailRow.svelte
Normal file
98
frontend/src/lib/components/ThumbnailRow.svelte
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ConversationThumbnail from '$lib/components/ConversationThumbnail.svelte';
|
||||||
|
import TagChipList from '$lib/components/TagChipList.svelte';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
|
type Tag = { id: string; name: string };
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
originalFilename: string;
|
||||||
|
documentDate?: string;
|
||||||
|
location?: string;
|
||||||
|
summary?: string;
|
||||||
|
contentType?: string;
|
||||||
|
thumbnailKey?: string;
|
||||||
|
thumbnailGeneratedAt?: string;
|
||||||
|
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
||||||
|
pageCount?: number;
|
||||||
|
sender?: Person | null;
|
||||||
|
receivers?: Person[];
|
||||||
|
tags?: Tag[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
doc,
|
||||||
|
isOut,
|
||||||
|
showOtherParty
|
||||||
|
}: {
|
||||||
|
doc: Doc;
|
||||||
|
isOut: boolean;
|
||||||
|
showOtherParty: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const title = $derived(doc.title || doc.originalFilename);
|
||||||
|
const otherPartyName = $derived(
|
||||||
|
showOtherParty
|
||||||
|
? isOut
|
||||||
|
? (doc.receivers?.[0]?.displayName ?? '')
|
||||||
|
: (doc.sender?.displayName ?? '')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
const directionLabel = $derived(isOut ? m.row_direction_sent() : m.row_direction_received());
|
||||||
|
const ariaLabel = $derived(
|
||||||
|
`${directionLabel}: ${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/documents/${doc.id}`}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
class="group flex min-h-[120px] items-start gap-3 border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 focus-within:bg-muted hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"
|
||||||
|
class:border-l-primary={isOut}
|
||||||
|
class:border-l-accent={!isOut}
|
||||||
|
>
|
||||||
|
<ConversationThumbnail doc={doc} />
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<img
|
||||||
|
data-testid="thumb-row-direction-icon"
|
||||||
|
src={isOut
|
||||||
|
? '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg'
|
||||||
|
: '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg'}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-5 w-5 shrink-0 opacity-70"
|
||||||
|
class:text-primary={isOut}
|
||||||
|
class:text-accent={!isOut}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1 truncate text-lg font-bold text-ink">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if doc.summary}
|
||||||
|
<div class="line-clamp-2 text-base text-ink-2 italic">
|
||||||
|
“{doc.summary}”
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-x-[6px] gap-y-1 text-sm text-ink-3">
|
||||||
|
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
|
||||||
|
{#if doc.location}
|
||||||
|
<span class="text-line">·</span>
|
||||||
|
<span>{doc.location}</span>
|
||||||
|
{/if}
|
||||||
|
{#if otherPartyName}
|
||||||
|
<span class="text-line">·</span>
|
||||||
|
<span>{otherPartyName}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TagChipList tags={doc.tags ?? []} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
228
frontend/src/lib/components/ThumbnailRow.svelte.spec.ts
Normal file
228
frontend/src/lib/components/ThumbnailRow.svelte.spec.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
import ThumbnailRow from './ThumbnailRow.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseDoc = {
|
||||||
|
id: 'd1',
|
||||||
|
title: 'Liebe Anna',
|
||||||
|
originalFilename: 'liebe_anna.pdf',
|
||||||
|
documentDate: '1950-06-01',
|
||||||
|
location: 'Berlin',
|
||||||
|
summary: 'Heute schreibe ich Dir, weil die Kinder gesund sind.',
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
thumbnailKey: 'thumbnails/d1.jpg',
|
||||||
|
thumbnailGeneratedAt: '2026-04-01T12:00:00Z',
|
||||||
|
thumbnailAspect: 'PORTRAIT' as const,
|
||||||
|
pageCount: 2,
|
||||||
|
sender: { id: 'hans', firstName: 'Hans', lastName: 'Müller', displayName: 'Hans Müller' },
|
||||||
|
receivers: [{ id: 'anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }],
|
||||||
|
tags: [
|
||||||
|
{ id: 't1', name: 'Familie' },
|
||||||
|
{ id: 't2', name: 'Krieg' },
|
||||||
|
{ id: 't3', name: 'Reise' },
|
||||||
|
{ id: 't4', name: 'Arbeit' },
|
||||||
|
{ id: 't5', name: 'Zuhause' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ThumbnailRow', () => {
|
||||||
|
it('renders the title, date, location, and summary quote', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Liebe Anna');
|
||||||
|
expect(document.body.textContent).toContain('Berlin');
|
||||||
|
expect(document.body.textContent).toContain('Heute schreibe ich Dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to originalFilename when title is empty', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: { ...baseDoc, title: '' },
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('liebe_anna.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the other-party name when showOtherParty=true (non-bilateral list)', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Out-going from Hans, other party is first receiver (Anna Schmidt)
|
||||||
|
expect(document.body.textContent).toContain('Anna Schmidt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the other-party name when showOtherParty=false (bilateral list)', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: false,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anna is the receiver; in a bilateral list we suppress party names.
|
||||||
|
expect(document.body.textContent).not.toContain('Anna Schmidt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders at most 3 tag chips and signals any remainder with "+N"', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||||
|
expect(chips.length).toBeLessThanOrEqual(3);
|
||||||
|
expect(document.body.textContent).toMatch(/\+2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a relative-year label', () => {
|
||||||
|
// Document date is historical; we deliberately omit the "vor N Jahren"
|
||||||
|
// chip so the row can give vertical space to the title + summary.
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).not.toMatch(/vor \d+ Jahr/);
|
||||||
|
expect(document.body.textContent).not.toMatch(/vor weniger/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the title at text-lg so the row uses its full vertical space', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleEl = [...document.querySelectorAll('div')].find(
|
||||||
|
(el) => el.textContent?.trim() === 'Liebe Anna' && el.className.includes('truncate')
|
||||||
|
) as HTMLElement | undefined;
|
||||||
|
expect(titleEl, 'title element not found').toBeDefined();
|
||||||
|
expect(titleEl!.className).toContain('text-lg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a right-arrow icon for outgoing letters', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const arrow = document.querySelector(
|
||||||
|
'[data-testid="thumb-row-direction-icon"]'
|
||||||
|
) as HTMLImageElement | null;
|
||||||
|
expect(arrow).not.toBeNull();
|
||||||
|
expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Right/);
|
||||||
|
// Decorative — direction is already announced via the aria-label prefix.
|
||||||
|
expect(arrow!.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a left-arrow icon for incoming letters', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: false,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const arrow = document.querySelector(
|
||||||
|
'[data-testid="thumb-row-direction-icon"]'
|
||||||
|
) as HTMLImageElement | null;
|
||||||
|
expect(arrow).not.toBeNull();
|
||||||
|
expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Left/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets border-l class based on isOut', () => {
|
||||||
|
const { unmount } = render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
let link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||||
|
expect(link.className).toContain('border-l-primary');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: false,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||||
|
expect(link.className).toContain('border-l-accent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a descriptive aria-label combining direction, title, and date', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||||
|
const label = link.getAttribute('aria-label') ?? '';
|
||||||
|
// Direction label routes through Paraglide so EN / ES users don't hear
|
||||||
|
// "Gesendet" in their screen reader.
|
||||||
|
expect(label.startsWith(`${m.row_direction_sent()}:`)).toBe(true);
|
||||||
|
expect(label).toContain('Liebe Anna');
|
||||||
|
expect(label).toMatch(/1950/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label leads with the received direction label for incoming letters', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: baseDoc,
|
||||||
|
isOut: false,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
||||||
|
const label = link.getAttribute('aria-label') ?? '';
|
||||||
|
expect(label.startsWith(`${m.row_direction_received()}:`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: {
|
||||||
|
...baseDoc,
|
||||||
|
summary: 'safe <img src=x onerror="alert(1)"> text'
|
||||||
|
},
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// No real img tag from the summary, the ConversationThumbnail img is fine.
|
||||||
|
const imgs = document.querySelectorAll('img[onerror]');
|
||||||
|
expect(imgs.length).toBe(0);
|
||||||
|
expect(document.body.textContent).toContain('<img src=x onerror="alert(1)">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing optional fields without crashing', () => {
|
||||||
|
render(ThumbnailRow, {
|
||||||
|
doc: {
|
||||||
|
id: 'n1',
|
||||||
|
title: 'Ohne Datum',
|
||||||
|
originalFilename: 'x.pdf',
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
thumbnailAspect: 'PORTRAIT'
|
||||||
|
},
|
||||||
|
isOut: true,
|
||||||
|
showOtherParty: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Ohne Datum');
|
||||||
|
});
|
||||||
|
});
|
||||||
76
frontend/src/lib/components/TranscribeCoachEmptyState.svelte
Normal file
76
frontend/src/lib/components/TranscribeCoachEmptyState.svelte
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="border-brand-sand rounded-sm border bg-white p-7 shadow-sm">
|
||||||
|
<h2 class="mb-3 font-serif text-[22px] font-bold text-ink">
|
||||||
|
{m.transcribe_coach_title()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-6 font-serif text-[15px] leading-relaxed text-ink-2">
|
||||||
|
{m.transcribe_coach_preamble()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
|
||||||
|
<!-- Step 1 -->
|
||||||
|
<li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||||
|
>1</span
|
||||||
|
>
|
||||||
|
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||||
|
<strong>{m.transcribe_coach_step_1_title()}</strong>
|
||||||
|
{m.transcribe_coach_step_1_body()}
|
||||||
|
<TranscribeDragDemo />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Step 2 -->
|
||||||
|
<li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||||
|
>2</span
|
||||||
|
>
|
||||||
|
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||||
|
<strong>{m.transcribe_coach_step_2_title()}</strong>
|
||||||
|
{m.transcribe_coach_step_2_body()}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Step 3 -->
|
||||||
|
<li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||||
|
>3</span
|
||||||
|
>
|
||||||
|
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||||
|
<strong>{m.transcribe_coach_step_3_title()}</strong>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="border-brand-sand mt-6 flex flex-wrap gap-4 border-t pt-3.5 font-sans text-[13px]">
|
||||||
|
<a
|
||||||
|
href="https://de.wikipedia.org/wiki/Kurrent"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||||
|
>
|
||||||
|
{m.transcribe_coach_footer_kurrent()}
|
||||||
|
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/hilfe/transkription"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||||
|
>
|
||||||
|
{m.transcribe_coach_footer_richtlinien()}
|
||||||
|
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||||
|
|
||||||
|
vi.mock('$lib/paraglide/messages.js', () => ({
|
||||||
|
m: {
|
||||||
|
transcribe_coach_title: () => 'Erste Transkription?',
|
||||||
|
transcribe_coach_preamble: () => 'Unser Kurrent-Erkenner lernt noch.',
|
||||||
|
transcribe_coach_step_1_title: () => 'Rahmen ziehen.',
|
||||||
|
transcribe_coach_step_1_body: () => 'Klicken und ziehen Sie mit der Maus einen Rahmen.',
|
||||||
|
transcribe_coach_step_2_title: () => 'Text eingeben.',
|
||||||
|
transcribe_coach_step_2_body: () => 'Geben Sie den Text ein.',
|
||||||
|
transcribe_coach_step_3_title: () => 'Speichert automatisch.',
|
||||||
|
transcribe_coach_footer_kurrent: () => 'Hilfe zu Kurrent ↗',
|
||||||
|
transcribe_coach_footer_richtlinien: () => 'Transkriptions-Richtlinien ↗',
|
||||||
|
common_opens_new_tab: () => '(öffnet in neuem Tab)'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TranscribeCoachEmptyState', () => {
|
||||||
|
it('renders the title and preamble', async () => {
|
||||||
|
render(TranscribeCoachEmptyState);
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('heading', { level: 2 }))
|
||||||
|
.toHaveTextContent('Erste Transkription?');
|
||||||
|
await expect.element(page.getByText('Unser Kurrent-Erkenner lernt noch.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders three numbered steps', async () => {
|
||||||
|
render(TranscribeCoachEmptyState);
|
||||||
|
await expect.element(page.getByText('Rahmen ziehen.')).toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(page.getByText('Klicken und ziehen Sie mit der Maus einen Rahmen.'))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Text eingeben.')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Geben Sie den Text ein.')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Speichert automatisch.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders footer links to Wikipedia Kurrent and Richtlinien page', async () => {
|
||||||
|
render(TranscribeCoachEmptyState);
|
||||||
|
const kurrentLink = page.getByRole('link', { name: /Hilfe zu Kurrent/ });
|
||||||
|
await expect.element(kurrentLink).toBeInTheDocument();
|
||||||
|
await expect.element(kurrentLink).toHaveAttribute('target', '_blank');
|
||||||
|
await expect.element(kurrentLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
await expect.element(kurrentLink).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||||
|
|
||||||
|
const richtlinienLink = page.getByRole('link', { name: /Transkriptions-Richtlinien/ });
|
||||||
|
await expect.element(richtlinienLink).toBeInTheDocument();
|
||||||
|
await expect.element(richtlinienLink).toHaveAttribute('target', '_blank');
|
||||||
|
await expect.element(richtlinienLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders visible "(öffnet in neuem Tab)" annotation on each footer link', async () => {
|
||||||
|
render(TranscribeCoachEmptyState);
|
||||||
|
const annotations = page.getByText('(öffnet in neuem Tab)');
|
||||||
|
await expect.element(annotations.first()).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the drag demo animation region inside step 1', async () => {
|
||||||
|
render(TranscribeCoachEmptyState);
|
||||||
|
const demo = page.getByRole('img', { name: /Rahmen ziehen|Animation/i });
|
||||||
|
await expect.element(demo).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
217
frontend/src/lib/components/TranscribeDragDemo.svelte
Normal file
217
frontend/src/lib/components/TranscribeDragDemo.svelte
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// $derived from .matches is a one-shot snapshot — it doesn't react when the
|
||||||
|
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
|
||||||
|
let prefersReducedMotion = $state(
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
prefersReducedMotion = e.matches;
|
||||||
|
};
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if prefersReducedMotion}
|
||||||
|
<!-- Static final frame for reduced-motion users -->
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
|
||||||
|
viewBox="0 0 600 180"
|
||||||
|
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
stroke="#2a2a2a"
|
||||||
|
stroke-width="1.6"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
|
||||||
|
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
|
||||||
|
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
|
||||||
|
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
|
||||||
|
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
|
||||||
|
</g>
|
||||||
|
<line
|
||||||
|
x1="60"
|
||||||
|
y1="120"
|
||||||
|
x2="540"
|
||||||
|
y2="120"
|
||||||
|
stroke="#D4D1C4"
|
||||||
|
stroke-width="0.8"
|
||||||
|
stroke-dasharray="2 3"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="55"
|
||||||
|
y="68"
|
||||||
|
width="470"
|
||||||
|
height="57"
|
||||||
|
fill="rgba(166, 218, 216, 0.12)"
|
||||||
|
stroke="#002850"
|
||||||
|
stroke-width="2.2"
|
||||||
|
/>
|
||||||
|
<g transform="translate(515, 58)">
|
||||||
|
<circle cx="0" cy="0" r="9" fill="#002850" />
|
||||||
|
<path
|
||||||
|
d="M -4 0 L -1 3 L 4 -3"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Animated 5-second drawing loop -->
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
|
||||||
|
viewBox="0 0 600 180"
|
||||||
|
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||||
|
>
|
||||||
|
<!-- Kurrent writing (static) -->
|
||||||
|
<g
|
||||||
|
stroke="#2a2a2a"
|
||||||
|
stroke-width="1.6"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
|
||||||
|
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
|
||||||
|
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
|
||||||
|
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
|
||||||
|
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
|
||||||
|
</g>
|
||||||
|
<line
|
||||||
|
x1="60"
|
||||||
|
y1="120"
|
||||||
|
x2="540"
|
||||||
|
y2="120"
|
||||||
|
stroke="#D4D1C4"
|
||||||
|
stroke-width="0.8"
|
||||||
|
stroke-dasharray="2 3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Click ripple -->
|
||||||
|
<circle cx="55" cy="68" r="0" fill="none" stroke="#A6DAD8" stroke-width="2.5" opacity="0">
|
||||||
|
<animate
|
||||||
|
attributeName="r"
|
||||||
|
values="0;0;4;18;0;0"
|
||||||
|
keyTimes="0;0.17;0.19;0.24;0.26;1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0;0;1;0;0;0"
|
||||||
|
keyTimes="0;0.17;0.19;0.24;0.26;1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- Growing selection rectangle -->
|
||||||
|
<rect
|
||||||
|
x="55"
|
||||||
|
y="68"
|
||||||
|
width="0"
|
||||||
|
height="0"
|
||||||
|
fill="rgba(166, 218, 216, 0.12)"
|
||||||
|
stroke="#002850"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-dasharray="5 4"
|
||||||
|
opacity="0"
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0;0;1;1;1;1;0;0"
|
||||||
|
keyTimes="0;0.18;0.20;0.88;0.92;0.94;0.98;1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="width"
|
||||||
|
values="0;0;470;470;470;470;0"
|
||||||
|
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="height"
|
||||||
|
values="0;0;57;57;57;57;0"
|
||||||
|
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="stroke-dasharray"
|
||||||
|
values="5 4;5 4;5 4;1 0;1 0;5 4"
|
||||||
|
keyTimes="0;0.60;0.64;0.68;0.94;1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="stroke-width"
|
||||||
|
values="2;2;2;3.2;2.2;2;2"
|
||||||
|
keyTimes="0;0.64;0.66;0.68;0.72;0.90;1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
|
||||||
|
<!-- Confirmation checkmark badge -->
|
||||||
|
<g opacity="0" transform="translate(515, 58)">
|
||||||
|
<circle cx="0" cy="0" r="9" fill="#002850" />
|
||||||
|
<path
|
||||||
|
d="M -4 0 L -1 3 L 4 -3"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0;0;1;1;0;0"
|
||||||
|
keyTimes="0;0.66;0.70;0.86;0.92;1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Cursor arrow -->
|
||||||
|
<g>
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="translate"
|
||||||
|
values="15,20; 55,68; 55,68; 525,125; 525,125; 15,20"
|
||||||
|
keyTimes="0; 0.15; 0.20; 0.62; 0.92; 1"
|
||||||
|
calcMode="spline"
|
||||||
|
keySplines="0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="1;1;1;0;0;1"
|
||||||
|
keyTimes="0;0.92;0.94;0.96;0.99;1"
|
||||||
|
dur="5s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 0 0 L 0 16 L 4.5 12 L 7.5 18 L 10.5 16.6 L 7.8 10.6 L 13 9 Z"
|
||||||
|
fill="#002850"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="0.8"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TranscribeDragDemo', () => {
|
||||||
|
it('renders an SVG with an aria-label describing the animation', async () => {
|
||||||
|
render(TranscribeDragDemo);
|
||||||
|
const svg = page.getByRole('img');
|
||||||
|
await expect.element(svg).toBeInTheDocument();
|
||||||
|
await expect.element(svg).toHaveAttribute('aria-label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains a dashed-border rectangle animation element', async () => {
|
||||||
|
const { container } = render(TranscribeDragDemo);
|
||||||
|
const rect = container.querySelector('rect');
|
||||||
|
expect(rect).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
import OcrTrigger from './OcrTrigger.svelte';
|
import OcrTrigger from './OcrTrigger.svelte';
|
||||||
|
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||||
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||||
@@ -231,28 +232,12 @@ async function handleLabelToggle(label: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
<div class="p-4">
|
||||||
<svg
|
<TranscribeCoachEmptyState />
|
||||||
class="mb-4 h-16 w-16 text-ink-3"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
|
|
||||||
{m.transcription_empty_draw_hint()}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canWrite}
|
{#if canWrite && hasBlocks}
|
||||||
<div class="border-t border-line px-4 py-3">
|
<div class="border-t border-line px-4 py-3">
|
||||||
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
|
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -61,9 +61,21 @@ describe('TranscriptionEditView — rendering', () => {
|
|||||||
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
|
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows empty state when no blocks', async () => {
|
it('shows coach card when no blocks', async () => {
|
||||||
renderView({ blocks: [] });
|
renderView({ blocks: [] });
|
||||||
await expect.element(page.getByText(/Zeichnen Sie Bereiche/)).toBeInTheDocument();
|
await expect
|
||||||
|
.element(page.getByRole('heading', { level: 2 }))
|
||||||
|
.toHaveTextContent('Erste Transkription?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides training footer when no blocks', async () => {
|
||||||
|
renderView({ blocks: [], canWrite: true });
|
||||||
|
await expect.element(page.getByText('Für Training vormerken')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows training footer when blocks exist', async () => {
|
||||||
|
renderView({ blocks: [block1], canWrite: true });
|
||||||
|
await expect.element(page.getByText('Für Training vormerken')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||||
|
import HelpPopover from './HelpPopover.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: 'read' | 'edit';
|
mode: 'read' | 'edit';
|
||||||
@@ -33,31 +34,36 @@ function handleReadClick() {
|
|||||||
<div
|
<div
|
||||||
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||||
>
|
>
|
||||||
<!-- Segmented toggle -->
|
<!-- Segmented toggle + help chip -->
|
||||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
<div class="flex items-center gap-1.5">
|
||||||
<button
|
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||||
type="button"
|
<button
|
||||||
data-testid="mode-read"
|
type="button"
|
||||||
aria-disabled={!hasBlocks}
|
data-testid="mode-read"
|
||||||
onclick={handleReadClick}
|
aria-disabled={!hasBlocks}
|
||||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
onclick={handleReadClick}
|
||||||
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||||
? 'bg-primary text-primary-fg'
|
? 'bg-primary text-primary-fg'
|
||||||
: 'text-ink-2 hover:text-ink'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||||
>
|
>
|
||||||
{m.mode_read()}
|
{m.mode_read()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="mode-edit"
|
data-testid="mode-edit"
|
||||||
onclick={() => onModeChange('edit')}
|
onclick={() => onModeChange('edit')}
|
||||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||||
? 'bg-primary text-primary-fg'
|
? 'bg-primary text-primary-fg'
|
||||||
: 'text-ink-2 hover:text-ink'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
>
|
>
|
||||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<HelpPopover label={m.transcription_mode_help_label()}>
|
||||||
|
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
||||||
|
</HelpPopover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status line (hidden on mobile to save space) -->
|
<!-- Status line (hidden on mobile to save space) -->
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('TranscriptionPanelHeader', () => {
|
describe('TranscriptionPanelHeader', () => {
|
||||||
it('should render Lesen and Bearbeiten buttons', async () => {
|
it('should render Lesen and Bearbeiten buttons', async () => {
|
||||||
render(TranscriptionPanelHeader, {
|
render(TranscriptionPanelHeader, {
|
||||||
@@ -148,4 +150,33 @@ describe('TranscriptionPanelHeader', () => {
|
|||||||
expect(statusText).not.toBeNull();
|
expect(statusText).not.toBeNull();
|
||||||
expect(statusText!.textContent).toContain('2026');
|
expect(statusText!.textContent).toContain('2026');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a (?) help chip next to the Read/Edit toggle', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 3,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||||
|
expect(helpBtn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a help popover with mode explanation when the chip is clicked', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 3,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||||
|
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
|
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
|
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||||
|
import BulkDropZone from './BulkDropZone.svelte';
|
||||||
|
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||||
|
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||||
|
import ScopeCard from './ScopeCard.svelte';
|
||||||
|
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||||
|
import WhoWhenSection from './WhoWhenSection.svelte';
|
||||||
|
import DescriptionSection from './DescriptionSection.svelte';
|
||||||
|
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||||
|
import { bulkTitleFromFilename } from '$lib/utils/filename';
|
||||||
|
import type { Tag } from '$lib/components/TagInput.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
|
// Mirrors the backend `DocumentBatchSummary` JSON shape one-to-one — the route
|
||||||
|
// passes the parsed `/api/documents/batch-metadata` response straight in, so
|
||||||
|
// the field names must match what the backend actually serializes (id, not
|
||||||
|
// documentId). The FileEntry built from each summary still uses both `id` and
|
||||||
|
// `documentId` so the save handler can drive the PATCH payload by UUID.
|
||||||
|
export type BulkEditEntry = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
pdfUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
||||||
|
let _confirmService: ConfirmService | null;
|
||||||
|
try {
|
||||||
|
_confirmService = getConfirmService();
|
||||||
|
} catch {
|
||||||
|
_confirmService = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
mode = 'upload',
|
||||||
|
initialSenderId = '',
|
||||||
|
initialSenderName = '',
|
||||||
|
initialReceivers = [],
|
||||||
|
initialEditEntries = []
|
||||||
|
}: {
|
||||||
|
mode?: 'upload' | 'edit';
|
||||||
|
initialSenderId?: string;
|
||||||
|
initialSenderName?: string;
|
||||||
|
initialReceivers?: Person[];
|
||||||
|
initialEditEntries?: BulkEditEntry[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// --- File state ---
|
||||||
|
let files = new SvelteMap<string, FileEntry>();
|
||||||
|
let activeId = $state<string | null>(null);
|
||||||
|
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||||
|
let saving = $state(false);
|
||||||
|
// Partial-failure surface: when set, the last save aborted at chunk N of M.
|
||||||
|
let partialSaved = $state<{ done: number; total: number } | null>(null);
|
||||||
|
|
||||||
|
// --- Shared metadata ---
|
||||||
|
let senderId = $state(untrack(() => initialSenderId));
|
||||||
|
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
||||||
|
let dateIso = $state('');
|
||||||
|
let tags = $state<Tag[]>([]);
|
||||||
|
// Bulk-edit only — replace-on-non-blank semantics.
|
||||||
|
let archiveBox = $state('');
|
||||||
|
let archiveFolder = $state('');
|
||||||
|
|
||||||
|
// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the
|
||||||
|
// fetch upstream in the route — by the time this layout mounts, the metadata
|
||||||
|
// has already been resolved into `initialEditEntries`. Wrapped in onMount so
|
||||||
|
// the SvelteMap mutation is unambiguously tied to instance lifecycle, not to
|
||||||
|
// the script body's first execution (Felix C4 cycle 3).
|
||||||
|
onMount(() => {
|
||||||
|
if (mode !== 'edit') return;
|
||||||
|
for (const entry of untrack(() => initialEditEntries)) {
|
||||||
|
files.set(entry.id, {
|
||||||
|
id: entry.id,
|
||||||
|
documentId: entry.id,
|
||||||
|
title: entry.title,
|
||||||
|
status: 'idle',
|
||||||
|
previewUrl: entry.pdfUrl
|
||||||
|
});
|
||||||
|
if (!activeId) activeId = entry.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Derived ---
|
||||||
|
const isMulti = $derived(files.size >= 2);
|
||||||
|
const activeFile = $derived(activeId ? files.get(activeId) : null);
|
||||||
|
|
||||||
|
// --- File management ---
|
||||||
|
function addFiles(newFiles: File[]) {
|
||||||
|
for (const file of newFiles) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const title = bulkTitleFromFilename(file.name);
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
files.set(id, { id, file, title, status: 'idle', previewUrl });
|
||||||
|
if (!activeId) activeId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(id: string) {
|
||||||
|
const entry = files.get(id);
|
||||||
|
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||||
|
files.delete(id);
|
||||||
|
if (activeId === id) {
|
||||||
|
activeId = files.keys().next().value ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitle(id: string, title: string) {
|
||||||
|
const entry = files.get(id);
|
||||||
|
if (entry) files.set(id, { ...entry, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardAll() {
|
||||||
|
for (const entry of files.values()) {
|
||||||
|
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||||
|
}
|
||||||
|
files.clear();
|
||||||
|
activeId = null;
|
||||||
|
chunkProgress = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDiscard() {
|
||||||
|
if (_confirmService) {
|
||||||
|
const ok = await _confirmService.confirm({
|
||||||
|
title: m.bulk_discard_all(),
|
||||||
|
body: m.bulk_discard_confirm(),
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
if (mode === 'edit') {
|
||||||
|
// In edit mode the file map IS the user's bulk selection — discarding
|
||||||
|
// must clear the upstream store and bounce back to the list, otherwise
|
||||||
|
// the user is left on /documents/bulk-edit with an empty form and a
|
||||||
|
// stale count in the bottom bar (issue #225 Bulk-Edit Panel table).
|
||||||
|
bulkSelectionStore.clear();
|
||||||
|
discardAll();
|
||||||
|
await goto('/documents');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
discardAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const entry of files.values()) {
|
||||||
|
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Save (upload mode) ---
|
||||||
|
async function saveUpload() {
|
||||||
|
const entries = Array.from(files.values());
|
||||||
|
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
|
||||||
|
const chunkSize = 10;
|
||||||
|
const chunks: FileEntry[][] = [];
|
||||||
|
for (let i = 0; i < entries.length; i += chunkSize) {
|
||||||
|
chunks.push(entries.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
chunkProgress = { done: 0, total: chunks.length };
|
||||||
|
|
||||||
|
let hadErrors = false;
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const chunk = chunks[i];
|
||||||
|
const formData = new FormData();
|
||||||
|
chunk.forEach((entry) => entry.file && formData.append('files', entry.file));
|
||||||
|
const metadata = {
|
||||||
|
titles: chunk.map((e) => e.title),
|
||||||
|
senderId: senderId || null,
|
||||||
|
receiverIds: selectedReceivers.map((r) => r.id),
|
||||||
|
documentDate: dateIso || null,
|
||||||
|
tagNames: tags.map((t) => t.name)
|
||||||
|
};
|
||||||
|
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||||
|
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
|
||||||
|
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||||
|
// by the browser for same-origin requests.
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||||
|
const body = await res.json().catch(() => ({ errors: [] }));
|
||||||
|
const errorFilenames = new Set<string>(
|
||||||
|
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||||
|
);
|
||||||
|
if (!res.ok || errorFilenames.size > 0) {
|
||||||
|
hadErrors = true;
|
||||||
|
for (const entry of chunk) {
|
||||||
|
const filename = entry.file?.name;
|
||||||
|
const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true;
|
||||||
|
if (isError) {
|
||||||
|
const e = files.get(entry.id);
|
||||||
|
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
hadErrors = true;
|
||||||
|
for (const entry of chunk) {
|
||||||
|
const e = files.get(entry.id);
|
||||||
|
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunkProgress = { done: i + 1, total: chunks.length };
|
||||||
|
}
|
||||||
|
if (!hadErrors) goto('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Save (edit mode) ---
|
||||||
|
async function saveBulkEdit() {
|
||||||
|
const entries = Array.from(files.values());
|
||||||
|
const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x);
|
||||||
|
|
||||||
|
// PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk
|
||||||
|
// failure so the user sees a deterministic "X of N saved" outcome.
|
||||||
|
const chunkSize = 500;
|
||||||
|
const chunks: string[][] = [];
|
||||||
|
for (let i = 0; i < ids.length; i += chunkSize) {
|
||||||
|
chunks.push(ids.slice(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
chunkProgress = { done: 0, total: chunks.length };
|
||||||
|
partialSaved = null;
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
tagNames: tags.map((t) => t.name),
|
||||||
|
senderId: senderId || null,
|
||||||
|
receiverIds: selectedReceivers.map((r) => r.id),
|
||||||
|
archiveBox: archiveBox || null,
|
||||||
|
archiveFolder: archiveFolder || null
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const chunk = chunks[i];
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/documents/bulk', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...dto, documentIds: chunk })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
// Network/server failure: the chunk did not apply. Mark its entries
|
||||||
|
// as errored, surface partial-save state, and stop.
|
||||||
|
for (const id of chunk) {
|
||||||
|
const e = files.get(id);
|
||||||
|
if (e) files.set(id, { ...e, status: 'error' });
|
||||||
|
}
|
||||||
|
partialSaved = { done: i, total: chunks.length };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json().catch(() => null)) as {
|
||||||
|
updated: number;
|
||||||
|
errors: { id: string; message: string }[];
|
||||||
|
} | null;
|
||||||
|
if (body && body.errors && body.errors.length > 0) {
|
||||||
|
for (const err of body.errors) {
|
||||||
|
const e = files.get(err.id);
|
||||||
|
if (e) files.set(err.id, { ...e, status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const id of chunk) {
|
||||||
|
const e = files.get(id);
|
||||||
|
if (e) files.set(id, { ...e, status: 'error' });
|
||||||
|
}
|
||||||
|
partialSaved = { done: i, total: chunks.length };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunkProgress = { done: i + 1, total: chunks.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stillErrored = Array.from(files.values()).some((e) => e.status === 'error');
|
||||||
|
if (!stillErrored) {
|
||||||
|
bulkSelectionStore.clear();
|
||||||
|
goto('/documents');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (saving) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
if (mode === 'edit') {
|
||||||
|
await saveBulkEdit();
|
||||||
|
} else {
|
||||||
|
await saveUpload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retrySave() {
|
||||||
|
partialSaved = null;
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
|
||||||
|
<!-- Topbar -->
|
||||||
|
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="/documents"
|
||||||
|
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{m.btn_back_to_overview()}
|
||||||
|
</a>
|
||||||
|
<span class="text-ink-3" aria-hidden="true">·</span>
|
||||||
|
<span class="font-serif text-sm font-bold text-ink">
|
||||||
|
{#if mode === 'edit'}
|
||||||
|
{m.bulk_edit_topbar_title()}
|
||||||
|
{:else}
|
||||||
|
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if isMulti}
|
||||||
|
<span class="ml-auto flex items-center gap-3">
|
||||||
|
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
|
||||||
|
{#if mode === 'edit'}
|
||||||
|
{m.bulk_edit_count_pill({ count: files.size })}
|
||||||
|
{:else}
|
||||||
|
{m.bulk_count_pill({ count: files.size })}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="discard-all-btn"
|
||||||
|
onclick={handleDiscard}
|
||||||
|
class="text-xs font-medium text-red-600/70 hover:text-red-700"
|
||||||
|
>
|
||||||
|
{m.bulk_discard_all()}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Split panel -->
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
<!-- Left: PDF preview / drop zone (55%) -->
|
||||||
|
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
|
||||||
|
{#if mode === 'upload' && files.size === 0}
|
||||||
|
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
|
||||||
|
<BulkDropZone onFilesAdded={addFiles} />
|
||||||
|
{:else if files.size > 0}
|
||||||
|
<!-- PDF preview: blob URL in upload mode, server URL in edit mode -->
|
||||||
|
<div class="relative flex-1 overflow-hidden">
|
||||||
|
{#if activeFile}
|
||||||
|
<PdfViewer url={activeFile.previewUrl} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isMulti}
|
||||||
|
<!-- File switcher strip pinned to bottom of left panel -->
|
||||||
|
<FileSwitcherStrip
|
||||||
|
files={Array.from(files.values())}
|
||||||
|
activeId={activeId ?? ''}
|
||||||
|
onSelect={(id) => (activeId = id)}
|
||||||
|
onRemove={removeFile}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: metadata form (45%) -->
|
||||||
|
<div class="flex flex-[45] flex-col overflow-hidden">
|
||||||
|
<!-- Scrollable form area — greyed out and non-interactive when no files selected -->
|
||||||
|
<div
|
||||||
|
class="flex-1 space-y-4 overflow-y-auto p-4 transition-opacity"
|
||||||
|
class:opacity-60={files.size === 0}
|
||||||
|
class:pointer-events-none={files.size === 0}
|
||||||
|
>
|
||||||
|
{#if mode === 'edit'}
|
||||||
|
<!-- Onboarding callout: tells the user that empty fields are skipped
|
||||||
|
and that tags/receivers are added rather than replaced.
|
||||||
|
No aria-label — role=note + the visible text content is
|
||||||
|
self-describing; an aria-label would override that text for
|
||||||
|
AT users on non-DE locales. -->
|
||||||
|
<div
|
||||||
|
role="note"
|
||||||
|
data-testid="bulk-edit-callout"
|
||||||
|
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
|
||||||
|
>
|
||||||
|
{m.bulk_edit_hint()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isMulti}
|
||||||
|
<!-- N≥2: per-file card (title) + shared card (metadata) -->
|
||||||
|
<ScopeCard variant="per-file">
|
||||||
|
{#if activeFile}
|
||||||
|
{#if mode === 'edit'}
|
||||||
|
<div data-testid="readonly-title">
|
||||||
|
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.form_label_title()}
|
||||||
|
</span>
|
||||||
|
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.form_label_title()} <span class="text-danger">*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={activeFile.title}
|
||||||
|
oninput={(e) =>
|
||||||
|
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</ScopeCard>
|
||||||
|
|
||||||
|
<ScopeCard variant="shared" count={files.size}>
|
||||||
|
<WhoWhenSection
|
||||||
|
bind:senderId={senderId}
|
||||||
|
bind:selectedReceivers={selectedReceivers}
|
||||||
|
bind:dateIso={dateIso}
|
||||||
|
initialSenderName={initialSenderName}
|
||||||
|
hideDate={mode === 'edit'}
|
||||||
|
editMode={mode === 'edit'}
|
||||||
|
/>
|
||||||
|
<DescriptionSection
|
||||||
|
bind:tags={tags}
|
||||||
|
bind:archiveBox={archiveBox}
|
||||||
|
bind:archiveFolder={archiveFolder}
|
||||||
|
hideTitle
|
||||||
|
editMode={mode === 'edit'}
|
||||||
|
/>
|
||||||
|
</ScopeCard>
|
||||||
|
{:else}
|
||||||
|
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
{#if mode === 'edit' && activeFile}
|
||||||
|
<div data-testid="readonly-title">
|
||||||
|
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.form_label_title()}
|
||||||
|
</span>
|
||||||
|
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.form_label_title()} <span class="text-danger">*</span>
|
||||||
|
</span>
|
||||||
|
{#if activeFile}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={activeFile.title}
|
||||||
|
oninput={(e) =>
|
||||||
|
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
placeholder="—"
|
||||||
|
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WhoWhenSection
|
||||||
|
bind:senderId={senderId}
|
||||||
|
bind:selectedReceivers={selectedReceivers}
|
||||||
|
bind:dateIso={dateIso}
|
||||||
|
initialSenderName={initialSenderName}
|
||||||
|
hideDate={mode === 'edit'}
|
||||||
|
editMode={mode === 'edit'}
|
||||||
|
/>
|
||||||
|
<DescriptionSection
|
||||||
|
bind:tags={tags}
|
||||||
|
bind:archiveBox={archiveBox}
|
||||||
|
bind:archiveFolder={archiveFolder}
|
||||||
|
hideTitle
|
||||||
|
editMode={mode === 'edit'}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if partialSaved}
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="bulk-edit-partial-failure"
|
||||||
|
class="rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
|
||||||
|
>
|
||||||
|
<p class="font-medium">
|
||||||
|
{m.bulk_edit_save_partial({
|
||||||
|
done: partialSaved.done,
|
||||||
|
total: partialSaved.total
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={retrySave}
|
||||||
|
class="mt-2 inline-flex items-center bg-primary px-4 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{m.bulk_edit_retry()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action bar: always visible at bottom of right panel -->
|
||||||
|
<UploadSaveBar
|
||||||
|
fileCount={files.size}
|
||||||
|
chunkProgress={chunkProgress}
|
||||||
|
onSave={save}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
disabled={saving}
|
||||||
|
editMode={mode === 'edit'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeFile(name: string): File {
|
||||||
|
return new File(['content'], name, { type: 'application/pdf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFilesViaInput(container: HTMLElement, files: File[]): Promise<void> {
|
||||||
|
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
if (!input) throw new Error('No file input found — is BulkDropZone visible?');
|
||||||
|
await userEvent.upload(input, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BulkDocumentEditLayout', () => {
|
||||||
|
it('N=0: shows BulkDropZone', async () => {
|
||||||
|
render(BulkDocumentEditLayout, {});
|
||||||
|
await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('N=1: file-switcher-strip and per-file scope card are absent', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||||
|
expect(container.querySelector('[data-variant="per-file"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('N=5: file-switcher-strip and per-file scope card are both present', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [
|
||||||
|
makeFile('a.pdf'),
|
||||||
|
makeFile('b.pdf'),
|
||||||
|
makeFile('c.pdf'),
|
||||||
|
makeFile('d.pdf'),
|
||||||
|
makeFile('e.pdf')
|
||||||
|
]);
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing middle file preserves order of remaining files', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [
|
||||||
|
makeFile('file0.pdf'),
|
||||||
|
makeFile('file1.pdf'),
|
||||||
|
makeFile('file2.pdf')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Remove the chip for file1 via its remove button (identified by data-remove-id)
|
||||||
|
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'[data-testid="file-switcher-strip"] button[data-remove-id]'
|
||||||
|
);
|
||||||
|
expect(removeButtons.length).toBe(3);
|
||||||
|
removeButtons[1].click(); // remove file1
|
||||||
|
|
||||||
|
// Wait for Svelte to flush the DOM update
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const chips = container.querySelectorAll(
|
||||||
|
'[data-testid="file-switcher-strip"] [data-chip-id]'
|
||||||
|
);
|
||||||
|
expect(chips.length).toBe(2);
|
||||||
|
expect(chips[0].textContent?.trim()).toContain('file0');
|
||||||
|
expect(chips[1].textContent?.trim()).toContain('file2');
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save calls fetch twice for 12 files (2 chunks of 10)', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ created: [], updated: [], errors: [] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`));
|
||||||
|
await addFilesViaInput(container, files);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(saveBtn).not.toBeNull();
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
// Wait for async save to complete
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save marks file as error when server returns non-ok response', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() includes tagNames in metadata payload', async () => {
|
||||||
|
let capturedFormData: FormData | undefined;
|
||||||
|
const mockFetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
||||||
|
capturedFormData = init?.body as FormData;
|
||||||
|
return { ok: true, json: async () => ({ created: [], updated: [], errors: [] }) };
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
|
||||||
|
expect(capturedFormData).toBeDefined();
|
||||||
|
const metadataBlob = capturedFormData!.get('metadata') as Blob;
|
||||||
|
const metadataJson = JSON.parse(await metadataBlob.text());
|
||||||
|
expect(metadataJson).toHaveProperty('tagNames');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() navigates to /documents when all chunks succeed', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ created: [], updated: [], errors: [] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() does not navigate when chunk returns non-ok response', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save marks only the file whose filename matches the backend error, not adjacent files', async () => {
|
||||||
|
// backend returns error keyed to b.pdf — only b.pdf chip should get data-status="error"
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||||
|
expect(errorChips.length).toBe(1);
|
||||||
|
expect(errorChips[0].textContent).toContain('b');
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => {
|
||||||
|
// Backend can return 200 OK while reporting individual file failures
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
created: [{ id: '1' }],
|
||||||
|
updated: [],
|
||||||
|
errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||||
|
expect(errorChips.length).toBe(1);
|
||||||
|
expect(errorChips[0].textContent).toContain('b');
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
// Navigation should be suppressed because hadErrors is true
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() marks all chunk files as errored when fetch throws a network error', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||||
|
expect(errorChips.length).toBe(2);
|
||||||
|
},
|
||||||
|
{ timeout: 3000 }
|
||||||
|
);
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() does not call fetch a second time when already saving', async () => {
|
||||||
|
let resolveFirst: (() => void) | undefined;
|
||||||
|
const mockFetch = vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
resolveFirst = () =>
|
||||||
|
resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ created: [], updated: [], errors: [] })
|
||||||
|
} as Response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click(); // first click — fetch is in-flight
|
||||||
|
saveBtn.click(); // second click — should be a no-op
|
||||||
|
|
||||||
|
resolveFirst?.();
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||||
|
|
||||||
|
// Confirm N=2 state — switcher is visible
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||||
|
|
||||||
|
// Click the topbar discard-all button (only visible in isMulti state)
|
||||||
|
const discardBtn = container.querySelector(
|
||||||
|
'button[data-testid="discard-all-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(discardBtn).not.toBeNull();
|
||||||
|
discardBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── mode="edit" ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('BulkDocumentEditLayout — mode="edit" discard', () => {
|
||||||
|
it('discard in edit mode clears the selection store and navigates back to /documents', async () => {
|
||||||
|
const { bulkSelectionStore } = await import('$lib/stores/bulkSelection.svelte');
|
||||||
|
bulkSelectionStore.setAll(['doc-1']);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [
|
||||||
|
{ id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' },
|
||||||
|
{ id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const discardBtn = container.querySelector(
|
||||||
|
'button[data-testid="discard-all-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(discardBtn).not.toBeNull();
|
||||||
|
discardBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 });
|
||||||
|
expect(bulkSelectionStore.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BulkDocumentEditLayout — mode="edit"', () => {
|
||||||
|
const editEntry = (i: number) => ({
|
||||||
|
id: `doc-${i}`,
|
||||||
|
title: `Brief ${i}`,
|
||||||
|
pdfUrl: `/api/documents/doc-${i}/file`
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the BulkDropZone in edit mode', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1)]
|
||||||
|
});
|
||||||
|
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the onboarding callout with role=note in edit mode', async () => {
|
||||||
|
render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1)]
|
||||||
|
});
|
||||||
|
const callout = page.getByTestId('bulk-edit-callout');
|
||||||
|
await expect.element(callout).toBeInTheDocument();
|
||||||
|
await expect.element(callout).toHaveAttribute('role', 'note');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders read-only title display (no input) in edit mode', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1)]
|
||||||
|
});
|
||||||
|
expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull();
|
||||||
|
// Per-file ScopeCard absent at N=1 — title rendered in the single card
|
||||||
|
const titleInput = container.querySelector('input[type="text"][value="Brief 1"]');
|
||||||
|
expect(titleInput).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the date field via WhoWhenSection hideDate prop', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1)]
|
||||||
|
});
|
||||||
|
expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows additive badge next to tags label', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1)]
|
||||||
|
});
|
||||||
|
expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows replace badges next to sender and archive fields', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1)]
|
||||||
|
});
|
||||||
|
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
|
||||||
|
// sender + archiveBox + archiveFolder = 3
|
||||||
|
expect(replaceBadges.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
|
||||||
|
// Elicit C1 fix — upload-flavoured "Mehrere Dokumente hochladen" /
|
||||||
|
// "werden erstellt" copy must not appear when mode === 'edit'.
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||||
|
});
|
||||||
|
// Topbar title slot
|
||||||
|
const topbar = container.querySelector('span.font-bold.text-ink');
|
||||||
|
expect(topbar?.textContent).toContain('Massenbearbeitung');
|
||||||
|
// Count pill
|
||||||
|
const pill = container.querySelector('span.bg-accent');
|
||||||
|
expect(pill?.textContent).toContain('werden bearbeitet');
|
||||||
|
// Negative: must NOT show upload-flavoured copy
|
||||||
|
expect(topbar?.textContent ?? '').not.toContain('hochladen');
|
||||||
|
expect(pill?.textContent ?? '').not.toContain('werden erstellt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1)]
|
||||||
|
});
|
||||||
|
expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save calls PATCH /api/documents/bulk in edit mode', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ updated: 2, errors: [] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(saveBtn).not.toBeNull();
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
const [url, init] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/documents/bulk');
|
||||||
|
expect(init.method).toBe('PATCH');
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
expect(body.documentIds).toEqual(['doc-1', 'doc-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chunks IDs into 500-sized PATCH requests', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ updated: 500, errors: [] })
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: entries
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 });
|
||||||
|
expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500);
|
||||||
|
expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500);
|
||||||
|
expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops on chunk failure and shows the partial-failure alert with retry', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) })
|
||||||
|
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) });
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: entries
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]');
|
||||||
|
expect(alert).not.toBeNull();
|
||||||
|
},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
// Should have called twice — chunks 0 and 1 — but not the third.
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(vi.mocked(goto)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks per-document error chips when service returns errors[]', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
updated: 1,
|
||||||
|
errors: [{ id: 'doc-2', message: 'Sender not found' }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
mode: 'edit',
|
||||||
|
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const errorChip = container.querySelector(
|
||||||
|
'[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]'
|
||||||
|
);
|
||||||
|
expect(errorChip).not.toBeNull();
|
||||||
|
},
|
||||||
|
{ timeout: 3000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onFilesAdded
|
||||||
|
}: {
|
||||||
|
onFilesAdded: (files: File[]) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label={m.bulk_drop_zone_label()}
|
||||||
|
aria-describedby="bulk-drop-desc"
|
||||||
|
data-testid="bulk-drop-zone"
|
||||||
|
class="flex flex-1 flex-col items-center justify-center p-6"
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (isDragging = false)}
|
||||||
|
ondrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||||
|
onFilesAdded(Array.from(e.dataTransfer.files));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'flex w-full max-w-xl flex-col items-center gap-5 rounded-md border-2 border-dashed px-12 py-16 text-center transition-colors',
|
||||||
|
isDragging ? 'border-accent bg-accent/10' : 'border-accent/50 bg-white/[0.04]'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<!-- Circular mint icon -->
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-accent text-primary">
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
fill="currentColor"
|
||||||
|
points="6 12.5 16 2 26 12.5 24.5714286 14 16.999 6.049 17 30 15 30 14.999 6.051 7.42857143 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Serif title -->
|
||||||
|
<p class="font-serif text-base font-bold text-ink">{m.bulk_drop_hint()}</p>
|
||||||
|
|
||||||
|
<!-- Sub description -->
|
||||||
|
<p id="bulk-drop-desc" class="text-sm leading-relaxed text-ink-2">{m.bulk_drop_desc()}</p>
|
||||||
|
|
||||||
|
<!-- CTA button -->
|
||||||
|
<label
|
||||||
|
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-primary px-6 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
{m.bulk_select_files()}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="application/pdf"
|
||||||
|
class="sr-only"
|
||||||
|
onchange={(e) => {
|
||||||
|
const files = Array.from(e.currentTarget.files ?? []);
|
||||||
|
if (files.length > 0) onFilesAdded(files);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Format hint -->
|
||||||
|
<p class="text-xs text-ink-3">{m.bulk_drop_sub()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user