Compare commits

..

52 Commits

Author SHA1 Message Date
Marcel
251b5503a2 test(security): add 403 permission test for annotation DELETE endpoint
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m0s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Failing after 2m51s
CI / Unit & Component Tests (pull_request) Failing after 3m26s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
Confirms that DELETE /api/documents/{id}/annotations/{id} requires at
least ANNOTATE_ALL; a user with only READ_ALL receives 403 Forbidden.
Closes the permission audit raised during PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:37:41 +02:00
Marcel
007ec65dbd fix(viewer): move delete button inside annotation bounds to prevent edge clipping
Repositioning from top:-8px/right:-8px to top:4px/right:4px ensures the
44px touch target stays fully within the annotation shape. Annotations drawn
near the top or right edge of the PDF page no longer risk the button being
obscured or inaccessible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:37:17 +02:00
Marcel
e95a9312e8 test(viewer): verify delete button click does not bubble to onclick
Documents the stopPropagation guarantee: clicking the trash button must
not trigger the annotation's onclick (which opens the block detail panel)
while the delete confirm is in progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:36:51 +02:00
Marcel
f22596a29d fix(viewer): check res.ok on orphaned annotation DELETE to surface errors
Without the guard, a failed DELETE (4xx/5xx) was silently swallowed and
annotationReloadKey was incremented anyway, leaving the annotation visible
and the user with no feedback. Now matches the deleteBlock() pattern
immediately above.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:36:27 +02:00
Marcel
b13c10936b feat(viewer): show delete icon on annotation for direct block deletion (#339)
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m11s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m4s
CI / Unit & Component Tests (pull_request) Failing after 3m7s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
Adds a trash icon button (44×44 px touch target) directly on each annotation shape in transcription mode so users can delete a block without navigating through the sidebar. Includes keyboard support (Delete key), confirm dialog via ConfirmService, prop-chain wiring through DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape, and orphaned-annotation fallback (calls DELETE /annotations/{id} when no block is linked). Backend security regression test added for deleteBlock 403 on READ_ALL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:00:50 +02:00
Marcel
ce41e96a45 test(audit): add 401 unauthenticated tests for createUser, adminUpdateUser, deleteUser
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m1s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 2m55s
Regression guards verifying that Spring Security returns 401 (not 200) when
no credentials are provided, complementing the existing 403 permission tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:44:03 +02:00
Marcel
a6c8af0971 test(audit): replace null-actorId bootstrap calls with createUserForBootstrap(), increase timeouts to 10s
Removes the wait+clear cycles that existed only to drain the audit events
emitted by createUserOrUpdate(null, ...). Timeouts increased 5 → 10 s to
reduce CI flakiness under load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:41:56 +02:00
Marcel
6d9910b805 refactor(audit): extract createUserForBootstrap() to make null actorId contract explicit
createUserOrUpdate(UUID actorId, ...) is always called from the controller with
a real authenticated actor. createUserForBootstrap() handles seeding/test setup
without emitting an audit event, making the two contracts unambiguous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:39:09 +02:00
Marcel
1dd6e054fc test(audit): add GROUP_MEMBERSHIP_CHANGED integration test with payload assertions
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m59s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m57s
CI / Unit & Component Tests (pull_request) Failing after 3m0s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
23cff1cdd7 refactor(audit): drop @DirtiesContext, add @BeforeEach, use existsByKind in wait conditions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
11d93919b2 refactor(audit): replace LIMIT :limit JPQL with Pageable in audit query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
f6bcc4f72a refactor(audit): extract actorId() helper in UserController
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
f4a4436eda test(audit): add 403 permission tests for createUser, adminUpdateUser, deleteUser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
1d3a3b3338 refactor(audit): extract groupChangePayload() from adminUpdateUser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:53:55 +02:00
Marcel
77affcfb4f test(audit): integration test — create + delete user produces ordered audit entries
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m4s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m2s
CI / Unit & Component Tests (push) Failing after 3m1s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 3m2s
Creates a real actor user first (needed for audit_log FK constraint),
then creates and deletes a target user, asserts USER_DELETED is newest
and USER_CREATED is second via findRecentUserManagementEvents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
36529f7e11 feat(audit): add findRecentUserManagementEvents query method
Adds findRecentByKinds JPQL query to AuditLogQueryRepository and
findRecentUserManagementEvents(int limit) to AuditLogQueryService,
returning the N most recent USER_CREATED/USER_DELETED/GROUP_MEMBERSHIP_CHANGED
events ordered newest-first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
eb8f9d4dc4 feat(audit): emit GROUP_MEMBERSHIP_CHANGED when admin updates user groups
Adds actorId param to adminUpdateUser(), captures beforeGroups before
mutation, computes added/removed group names, emits logAfterCommit only
when the group set actually changes. Payload contains group names, not
permission strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
a736b7399a feat(audit): emit USER_DELETED when admin removes a user
Adds actorId param to deleteUser(), captures email before deletion,
emits logAfterCommit(USER_DELETED) with userId+email in payload.
Updates UserController to resolve and pass actorId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
e7c7f801c9 feat(audit): emit USER_CREATED when admin creates a new user
Adds USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED to AuditKind.
Injects AuditService into UserService; changes createUserOrUpdate to
accept actorId and emits logAfterCommit(USER_CREATED) only on the
new-user branch. Updates UserController to resolve and pass actorId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:16:29 +02:00
Marcel
5062513ae6 refactor(persons): extract inputCls/labelCls and PersonFormData type
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m20s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 2m56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
24d5381775 refactor(persons): rename page.server.test.ts to normalizePersonType.test.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
826283afcb test(persons): replace fragile CSS class tests with aria-checked behavior tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
1d5f99a2c8 a11y(persons): add aria-label to PersonTypeSelector radiogroup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
5961bfb916 test(persons): assert error code in createPerson_returns400_whenPersonTypeIsSkip
Adds jsonPath("$.code").value("INVALID_PERSON_TYPE") to verify the full
error response shape, not just the HTTP status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
4c300da65e refactor(persons): remove what-comment from PersonCard title block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
bccff232fe fix(persons): localize validation error messages via Paraglide i18n
validatePersonFields now returns a PersonValidationKey instead of a
hardcoded German string. resolveValidationMessage() translates the key
through Paraglide so English and Spanish locale users no longer see
German error text. Adds validation_last_name_required and
validation_first_name_required to all three message files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
327fd89cb9 refactor(persons): centralise PersonType, PERSON_TYPES and normalizePersonType in person-validation
Removes four independent PersonType type declarations and the duplicated
TYPES/PERSON_TYPES arrays. normalizePersonType moves from the edit route
module into the shared lib so page.server.test.ts no longer imports from a
route. Both server actions now use normalizePersonType for personType
extraction instead of an inline type cast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
23861055d1 fix(persons): keyboard navigation now updates PersonTypeSelector reactive state
radioGroupNav now accepts an onChange callback; PersonTypeSelector passes
select() as the callback so ArrowLeft/Right navigation updates the hidden
input value. aria-live region starts empty and announces only on user
interaction (fixes initial page-load announcement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
2ddeb485e3 test(persons): extract validatePersonFields and cover validation branches
- New src/lib/person-validation.ts exports validatePersonFields (pure function)
- 8 unit tests covering: valid PERSON, lastName missing/undefined,
  firstName missing/undefined for PERSON, non-PERSON types without firstName
- Both edit and new-person server actions now call the shared helper instead
  of inline if-chains, making the logic testable and non-duplicated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
1f19fa3462 refactor(persons): export normalizePersonType from edit server module
Tests now import from production code instead of a local copy, giving real
regression protection if the inline logic is changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
7ef1ab3b01 fix(persons): trim title server-side and add SKIP controller test
- PersonController trims title (both create + update) matching the existing firstName/lastName trim pattern
- PersonControllerTest: verifies title is trimmed before service call (ArgumentCaptor)
- PersonControllerTest: verifies createPerson returns 400 when personType is SKIP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
45db75bdf2 fix(persons): use semantic color tokens in PersonTypeSelector for dark mode
Replaces hardcoded brand-navy/brand-sand/white classes with semantic
tokens (bg-primary/text-primary-fg, bg-surface/text-ink, border-line,
ring-focus-ring) so the segmented control adapts correctly in dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
8870cbe2fe feat(persons): show title in small-caps above display name in PersonCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
b4cf7f1b21 feat(persons): add type selector + title + conditional fields to new-person form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
d5587d1b95 feat(persons): extract personType + title in edit action; relax firstName for non-PERSON
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
7699a4e7e2 feat(persons): add type selector + title + conditional fields to edit form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
110416d68b feat(persons): add PersonTypeSelector segmented control component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
64fdc5b57e feat(i18n): add form_label_person_type, form_label_name, a11y_type_changed keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
ac8d0d5796 feat(persons): normalize SKIP→UNKNOWN in edit-route load function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
b8dcb2d3f4 feat(persons): add radioGroupNav action for keyboard navigation in type selector
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
ecd531601a feat(persons): relax firstName requirement for non-PERSON types in controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
fe1101f9d5 feat(persons): updatePerson rejects SKIP with INVALID_PERSON_TYPE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
928ebca056 feat(persons): updatePerson persists personType from DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
5dd4a01995 feat(persons): createPerson(DTO) rejects SKIP with INVALID_PERSON_TYPE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
f4132edc2b feat(persons): add personType to PersonUpdateDTO and wire into createPerson
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
d952fab4cd feat(persons): add INVALID_PERSON_TYPE error code with i18n translations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:37:34 +02:00
Marcel
d45739cb76 fix(search): use to_tsquery('simple') for prefix transform to avoid German stop word collision
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m51s
CI / OCR Service Tests (push) Successful in 56s
CI / Backend Unit Tests (push) Failing after 3m9s
Words like "Wille" stem to "will" via the German Snowball stemmer, which is
also a German stop word. The prefix-transform step (websearch_to_tsquery text →
regexp_replace → to_tsquery) was passing already-stemmed lexemes back through
the German dictionary, causing them to be silently dropped as stop words. Using
the 'simple' configuration skips stop-word processing entirely while the
tsvector @@ tsquery comparison still works because lexemes are matched by
string value, not by configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:56:55 +02:00
Marcel
18cad798fc fix(documents): preserve archiveBox + archiveFolder in markForReview; drop documentLocation
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m1s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m53s
2026-04-25 20:25:08 +02:00
Marcel
0ddf43947b refactor(documents): drop documentLocation binding from edit layouts; wire archive fields 2026-04-25 20:23:37 +02:00
Marcel
45f7642f8d feat(documents): replace documentLocation with archiveBox/archiveFolder in edit form 2026-04-25 20:11:30 +02:00
Marcel
5a13e61357 feat(documents): wire archiveBox + archiveFolder through DTO and service update 2026-04-25 20:08:21 +02:00
Marcel
a91ee1f26d refactor(documents): unify count + action links into one row
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 2m56s
Move result count, bulk-edit button, and new-document link into a shared
flex row so they appear on the same line. Adds an edit icon to the
bulk-edit button to visually match the existing plus icon on the add link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:41:24 +02:00
54 changed files with 1708 additions and 325 deletions

View File

@@ -26,7 +26,16 @@ public enum AuditKind {
COMMENT_ADDED,
/** 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(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,

View File

@@ -1,5 +1,7 @@
package org.raddatz.familienarchiv.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -197,4 +199,6 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
ORDER BY ranked.document_id, ranked.rn
""", nativeQuery = true)
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
}

View File

@@ -1,11 +1,17 @@
package org.raddatz.familienarchiv.audit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.*;
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
@Service
@RequiredArgsConstructor
public class AuditLogQueryService {
@@ -51,6 +57,11 @@ public class AuditLogQueryService {
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
}
public List<AuditLog> findRecentUserManagementEvents(int limit) {
PageRequest page = PageRequest.of(0, limit, Sort.by("happenedAt").descending());
return queryRepository.findByKindIn(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), page).getContent();
}
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) {

View File

@@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
boolean existsByKind(AuditKind kind);
}

View File

@@ -63,27 +63,33 @@ public class PersonController {
@PostMapping
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
dto.setFirstName(dto.getFirstName().trim());
validatePersonNames(dto);
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
return ResponseEntity.ok(personService.createPerson(dto));
}
@PutMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
dto.setFirstName(dto.getFirstName().trim());
validatePersonNames(dto);
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
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")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)

View File

@@ -78,24 +78,31 @@ public class UserController {
@PostMapping("/users")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(request));
public ResponseEntity<AppUser> createUser(Authentication authentication,
@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
}
@PutMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
@PathVariable UUID id,
@RequestBody AdminUpdateUserRequest dto) {
AppUser updated = userService.adminUpdateUser(id, dto);
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto);
updated.setPassword(null);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
userService.deleteUser(id);
public ResponseEntity<Void> deleteUser(Authentication authentication,
@PathVariable UUID id) {
userService.deleteUser(actorId(authentication), id);
return ResponseEntity.ok().build();
}
private UUID actorId(Authentication auth) {
return userService.findByEmail(auth.getName()).getId();
}
}

View File

@@ -13,6 +13,8 @@ public class DocumentUpdateDTO {
private LocalDate documentDate;
private String location;
private String documentLocation;
private String archiveBox;
private String archiveFolder;
private String transcription;
private String summary;
private UUID senderId;

View File

@@ -1,10 +1,14 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.raddatz.familienarchiv.model.PersonType;
@Data
public class PersonUpdateDTO {
@NotNull
private PersonType personType;
@Size(max = 50)
private String title;
@Size(max = 100)

View File

@@ -13,6 +13,8 @@ public enum ErrorCode {
PERSON_NOT_FOUND,
/** A person name alias with the given ID does not exist. 404 */
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
// --- Documents ---
/** A document with the given ID does not exist. 404 */

View File

@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
SELECT d.id FROM documents d
CROSS JOIN LATERAL (
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,
'''([^'']+)''',
'''\\1'':*',
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
FROM documents d
CROSS JOIN LATERAL (
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,
'''([^'']+)''',
'''\\1'':*',

View File

@@ -271,6 +271,8 @@ public class DocumentService {
doc.setTranscription(dto.getTranscription());
doc.setSummary(dto.getSummary());
doc.setDocumentLocation(dto.getDocumentLocation());
doc.setArchiveBox(dto.getArchiveBox());
doc.setArchiveFolder(dto.getArchiveFolder());
List<String> tags = new ArrayList<>();
if (dto.getTags() != null && !dto.getTags().isBlank()) {

View File

@@ -109,8 +109,12 @@ public class PersonService {
@Transactional
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());
Person person = Person.builder()
.personType(dto.getPersonType())
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
@@ -136,9 +140,13 @@ public class PersonService {
@Transactional
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());
Person person = personRepository.findById(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.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());

View File

@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
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.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -21,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static java.util.stream.Collectors.toSet;
@Service
@RequiredArgsConstructor
@Slf4j
@@ -33,9 +38,10 @@ public class UserService {
private final AppUserRepository userRepository;
private final UserGroupRepository groupRepository;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
@Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) {
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
@@ -45,10 +51,12 @@ public class UserService {
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
AppUser user;
boolean isNew;
if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getEmail());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
isNew = false;
} else {
log.info("Creating new user: {}", request.getEmail());
user = AppUser.builder()
@@ -61,8 +69,42 @@ public class UserService {
.contact(request.getContact())
.enabled(true)
.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);
}
@@ -94,10 +136,13 @@ public class UserService {
}
@Transactional
public void deleteUser(UUID userId) {
public void deleteUser(UUID actorId, UUID userId) {
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
String email = user.getEmail();
userRepository.delete(user);
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
Map.of("userId", userId.toString(), "email", email));
}
public AppUser getById(UUID id) {
@@ -141,7 +186,7 @@ public class UserService {
}
@Transactional
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
AppUser user = getById(id);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
@@ -166,13 +211,27 @@ public class UserService {
}
if (dto.getGroupIds() != null) {
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(groups);
Set<UserGroup> before = new HashSet<>(user.getGroups());
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);
}
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
public void changePassword(UUID userId, ChangePasswordDTO dto) {
AppUser user = getById(userId);

View File

@@ -6,12 +6,19 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
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.Set;
import java.util.UUID;
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.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,4 +54,21 @@ class AuditLogQueryServiceTest {
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
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));
}
}

View File

@@ -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");
}
}

View File

@@ -154,6 +154,13 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {

View File

@@ -1,6 +1,9 @@
package org.raddatz.familienarchiv.controller;
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.Person;
import org.raddatz.familienarchiv.model.PersonNameAlias;
@@ -25,6 +28,7 @@ import java.util.UUID;
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.eq;
import static org.mockito.Mockito.verify;
@@ -183,19 +187,19 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\"}"))
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -204,7 +208,7 @@ class PersonControllerTest {
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\"}"))
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -213,7 +217,7 @@ class PersonControllerTest {
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -225,11 +229,53 @@ class PersonControllerTest {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.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} ────────────────────────────────────────────────
@Test
@@ -242,10 +288,10 @@ class PersonControllerTest {
@Test
@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())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -254,7 +300,7 @@ class PersonControllerTest {
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\"}"))
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -267,7 +313,7 @@ class PersonControllerTest {
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastName").value("Müller"));
}
@@ -317,11 +363,10 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
// firstName valid, lastName blank → second || operand = true → 400
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -339,7 +384,7 @@ class PersonControllerTest {
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
"\"notes\":\"Some notes\"}"))
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria"))
@@ -355,7 +400,7 @@ class PersonControllerTest {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.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());
}
@@ -366,7 +411,7 @@ class PersonControllerTest {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -377,7 +422,7 @@ class PersonControllerTest {
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden());
}
@@ -386,7 +431,7 @@ class PersonControllerTest {
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden());
}

View File

@@ -260,6 +260,13 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deleteBlock_returns204_whenAuthorised() throws Exception {

View File

@@ -18,8 +18,10 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -104,4 +106,55 @@ class UserControllerTest {
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
.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());
}
}

View File

@@ -179,6 +179,22 @@ class DocumentFtsTest {
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
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
documentRepository.saveAndFlush(document("Brief"));

View File

@@ -121,6 +121,23 @@ class DocumentServiceTest {
.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 ───────────────────────────────────────────────────
@Test

View File

@@ -114,6 +114,43 @@ class PersonServiceTest {
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) ─────────────────────────────
@Test
@@ -145,6 +182,36 @@ class PersonServiceTest {
.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) ─────────────────────────────────────────────────
@Test

View File

@@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
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.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -34,6 +37,7 @@ class UserServiceTest {
@Mock AppUserRepository userRepository;
@Mock UserGroupRepository groupRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock AuditService auditService;
@InjectMocks UserService userService;
// ─── findByEmail ──────────────────────────────────────────────────────────
@@ -61,7 +65,7 @@ class UserServiceTest {
UUID id = UUID.randomUUID();
when(userRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.deleteUser(id))
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
.isInstanceOf(DomainException.class);
}
@@ -71,7 +75,7 @@ class UserServiceTest {
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
userService.deleteUser(id);
userService.deleteUser(UUID.randomUUID(), id);
verify(userRepository).delete(user);
}
@@ -90,7 +94,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
assertThat(result).isEqualTo(saved);
verify(userRepository).save(any());
@@ -108,7 +112,7 @@ class UserServiceTest {
when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(req);
userService.createUserOrUpdate(UUID.randomUUID(), req);
verify(userRepository, times(1)).save(existing);
}
@@ -229,7 +233,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
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.getLastName()).isEqualTo("Lovelace");
@@ -246,7 +250,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getGroups()).containsExactly(adminGroup);
}
@@ -264,7 +268,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
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);
}
@@ -281,7 +285,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of());
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getGroups()).isEmpty();
}
@@ -313,7 +317,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
assertThat(result).isEqualTo(saved);
verify(groupRepository).findAllById(List.of(group.getId()));
@@ -378,7 +382,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword("newSecret");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getPassword()).isEqualTo("newHashed");
}
@@ -393,7 +397,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword(" ");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getPassword()).isEqualTo("original");
verify(passwordEncoder, never()).encode(any());
@@ -408,7 +412,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(" ");
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("blank");
}
@@ -425,7 +429,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("taken@example.com");
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("E-Mail");
}
@@ -497,7 +501,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);
userService.createUserOrUpdate(UUID.randomUUID(), req);
verify(groupRepository, never()).findAllById(any());
}
@@ -561,7 +565,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(null);
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getContact()).isNull();
}
@@ -576,7 +580,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" ");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getContact()).isNull();
}
@@ -591,7 +595,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" phone: 555 ");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getContact()).isEqualTo("phone: 555");
}
@@ -606,7 +610,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(null);
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getEmail()).isEqualTo("keep@example.com");
}
@@ -622,7 +626,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
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");
}
@@ -640,7 +644,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);
userService.createUserOrUpdate(UUID.randomUUID(), req);
verify(groupRepository, never()).findAllById(any());
}
@@ -699,6 +703,160 @@ class UserServiceTest {
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 ──────────────────────────────────────────────────────────
@Test

View File

@@ -33,6 +33,8 @@
"btn_back_to_overview": "Zurück zur Übersicht",
"btn_back": "Zurück",
"btn_back_to_document": "Zurück zum Dokument",
"form_label_person_type": "Typ",
"form_label_name": "Name",
"form_label_first_name": "Vorname",
"form_label_last_name": "Nachname",
"form_label_alias": "Rufname / Alias",
@@ -527,6 +529,7 @@
"person_type_INSTITUTION": "Institution",
"person_type_GROUP": "Gruppe",
"person_type_UNKNOWN": "Unbekannt",
"a11y_type_changed": "Typ geändert zu {type}",
"person_alias_add_heading": "Name hinzufuegen",
"person_alias_label_type": "Art",
"person_alias_label_last_name": "Nachname",
@@ -536,6 +539,9 @@
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
"person_alias_btn_delete": "Entfernen",
"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_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",

View File

@@ -33,6 +33,8 @@
"btn_back_to_overview": "Back to overview",
"btn_back": "Back",
"btn_back_to_document": "Back to document",
"form_label_person_type": "Type",
"form_label_name": "Name",
"form_label_first_name": "First name",
"form_label_last_name": "Last name",
"form_label_alias": "Nickname / Alias",
@@ -527,6 +529,7 @@
"person_type_INSTITUTION": "Institution",
"person_type_GROUP": "Group",
"person_type_UNKNOWN": "Unknown",
"a11y_type_changed": "Type changed to {type}",
"person_alias_add_heading": "Add name",
"person_alias_label_type": "Type",
"person_alias_label_last_name": "Last name",
@@ -536,6 +539,9 @@
"person_alias_delete_body": "This name will be removed from search results.",
"person_alias_btn_delete": "Remove",
"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_job_not_found": "The OCR job was not found.",
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",

View File

@@ -33,6 +33,8 @@
"btn_back_to_overview": "Volver al resumen",
"btn_back": "Volver",
"btn_back_to_document": "Volver al documento",
"form_label_person_type": "Tipo",
"form_label_name": "Nombre",
"form_label_first_name": "Nombre",
"form_label_last_name": "Apellido",
"form_label_alias": "Apodo / Alias",
@@ -527,6 +529,7 @@
"person_type_INSTITUTION": "Institución",
"person_type_GROUP": "Grupo",
"person_type_UNKNOWN": "Desconocido",
"a11y_type_changed": "Tipo cambiado a {type}",
"person_alias_add_heading": "Agregar nombre",
"person_alias_label_type": "Tipo",
"person_alias_label_last_name": "Apellido",
@@ -536,6 +539,9 @@
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
"person_alias_btn_delete": "Eliminar",
"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_job_not_found": "No se encontró el trabajo OCR.",
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",

View 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]);
});
});

View 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);
}
};
}

View File

@@ -18,7 +18,8 @@ let {
dimmed = false,
flashAnnotationId = null,
onDraw,
onAnnotationClick
onAnnotationClick,
onDeleteRequest
}: {
annotations: Annotation[];
canDraw: boolean;
@@ -29,6 +30,7 @@ let {
flashAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void;
onDeleteRequest?: (annotationId: string) => void;
} = $props();
let drawStart = $state<{ x: number; y: number } | null>(null);
@@ -112,6 +114,8 @@ const containerStyle = $derived(
dimmed={dimmed}
blockNumber={blockNumbers[annotation.id]}
isFlashing={flashAnnotationId === annotation.id}
showDelete={canDraw}
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
onclick={() => onAnnotationClick?.(annotation.id)}
onpointerenter={() => (hoveredId = annotation.id)}
onpointerleave={() => (hoveredId = null)}

View File

@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
expect(el2.style.opacity).toBe('1');
});
it('does not show delete buttons (annotations owned by blocks)', async () => {
it('does not show delete button when annotation is not hovered or active', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: true,
@@ -107,6 +107,19 @@ describe('AnnotationLayer', () => {
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when canDraw is false even if annotation is active', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: false,
color: '#00C7B1',
activeAnnotationId: 'ann-1',
onDraw: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
});

View File

@@ -11,6 +11,8 @@ let {
blockNumber = undefined,
isFlashing = false,
isResizable = false,
showDelete = false,
onDeleteRequest,
onclick,
onpointerenter,
onpointerleave
@@ -23,11 +25,15 @@ let {
blockNumber?: number | undefined;
isFlashing?: boolean;
isResizable?: boolean;
showDelete?: boolean;
onDeleteRequest?: () => void;
onclick: () => void;
onpointerenter: () => void;
onpointerleave: () => void;
} = $props();
const deleteVisible = $derived(showDelete && (isHovered || isActive));
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
@@ -83,6 +89,7 @@ let shapeStyle = $derived(
onclick={onclick}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick();
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
}}
onpointerenter={onpointerenter}
onpointerleave={onpointerleave}
@@ -112,6 +119,51 @@ let shapeStyle = $derived(
{blockNumber}
</div>
{/if}
{#if deleteVisible}
<button
data-testid="annotation-delete-{annotation.id}"
type="button"
aria-label="Löschen"
onclick={(e) => {
e.stopPropagation();
onDeleteRequest?.();
}}
style="
position: absolute;
top: 4px;
right: 4px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #fff;
border: 1px solid var(--color-error, #e53e3e);
color: var(--color-error, #e53e3e);
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
z-index: 10;
"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{#if isResizable}
<AnnotationEditOverlay annotation={annotation} />
{/if}

View File

@@ -0,0 +1,177 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationShape from './AnnotationShape.svelte';
afterEach(cleanup);
function makeAnnotation(id = 'ann-1') {
return {
id,
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.2,
color: '#00C7B1',
createdAt: new Date().toISOString()
};
}
describe('AnnotationShape', () => {
it('renders the annotation element', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
});
it('does not show delete button when showDelete is false', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: false,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('shows delete button when showDelete is true and isHovered is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('shows delete button when showDelete is true and isActive is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('calls onDeleteRequest when delete button is clicked', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onclick when delete button is clicked', async () => {
const onclick = vi.fn();
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick,
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onclick).not.toHaveBeenCalled();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: false,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).not.toHaveBeenCalled();
});
});

View File

@@ -24,6 +24,7 @@ type Props = {
flashAnnotationId?: string | null;
onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
};
let {
@@ -38,7 +39,8 @@ let {
annotationsDimmed = false,
flashAnnotationId = null,
onAnnotationClick,
onTranscriptionDraw
onTranscriptionDraw,
onDeleteAnnotationRequest
}: Props = $props();
</script>
@@ -98,6 +100,7 @@ let {
flashAnnotationId={flashAnnotationId}
onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw}
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}

View File

@@ -18,6 +18,7 @@ let {
activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick,
onTranscriptionDraw,
onDeleteAnnotationRequest,
documentFileHash,
annotationsDimmed = false,
flashAnnotationId = null
@@ -30,6 +31,7 @@ let {
activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
documentFileHash?: string | null;
annotationsDimmed?: boolean;
flashAnnotationId?: string | null;
@@ -264,6 +266,7 @@ function handleAnnotationClick(id: string) {
flashAnnotationId={flashAnnotationId}
onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick}
onDeleteRequest={onDeleteAnnotationRequest}
/>
{/if}
</div>

View 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>

View File

@@ -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');
}
});
});

View File

@@ -67,7 +67,6 @@ let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
let dateIso = $state('');
let tags = $state<Tag[]>([]);
// Bulk-edit only — replace-on-non-blank semantics.
let documentLocation = $state('');
let archiveBox = $state('');
let archiveFolder = $state('');
@@ -231,7 +230,6 @@ async function saveBulkEdit() {
tagNames: tags.map((t) => t.name),
senderId: senderId || null,
receiverIds: selectedReceivers.map((r) => r.id),
documentLocation: documentLocation || null,
archiveBox: archiveBox || null,
archiveFolder: archiveFolder || null
};
@@ -442,7 +440,6 @@ async function retrySave() {
/>
<DescriptionSection
bind:tags={tags}
bind:documentLocation={documentLocation}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle
@@ -494,7 +491,6 @@ async function retrySave() {
/>
<DescriptionSection
bind:tags={tags}
bind:documentLocation={documentLocation}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle

View File

@@ -397,8 +397,8 @@ describe('BulkDocumentEditLayout — mode="edit"', () => {
initialEditEntries: [editEntry(1)]
});
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
// sender + documentLocation + archiveBox + archiveFolder = 4
expect(replaceBadges.length).toBeGreaterThanOrEqual(4);
// sender + archiveBox + archiveFolder = 3
expect(replaceBadges.length).toBeGreaterThanOrEqual(3);
});
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {

View File

@@ -11,7 +11,8 @@ let {
archiveBox = $bindable(''),
archiveFolder = $bindable(''),
initialTitle = '',
initialDocumentLocation = '',
initialArchiveBox = '',
initialArchiveFolder = '',
initialSummary = '',
titleRequired = false,
suggestedTitle = '',
@@ -24,7 +25,8 @@ let {
archiveBox?: string;
archiveFolder?: string;
initialTitle?: string;
initialDocumentLocation?: string;
initialArchiveBox?: string;
initialArchiveFolder?: string;
initialSummary?: string;
titleRequired?: boolean;
suggestedTitle?: string;
@@ -41,7 +43,8 @@ let {
let titleDirty = $state(false);
onMount(() => {
if (!currentTitle && initialTitle) currentTitle = initialTitle;
if (!documentLocation && initialDocumentLocation) documentLocation = initialDocumentLocation;
if (!archiveBox && initialArchiveBox) archiveBox = initialArchiveBox;
if (!archiveFolder && initialArchiveFolder) archiveFolder = initialArchiveFolder;
});
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
</script>
@@ -110,55 +113,36 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
</div>
{/if}
<!-- Aufbewahrungsort (optional) -->
<div data-testid="description-document-location">
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_archive_location()}
<!-- Karton -->
<div data-testid="description-archive-box">
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_box()}
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
</label>
<input
id="documentLocation"
id="archiveBox"
type="text"
name="documentLocation"
bind:value={documentLocation}
placeholder={m.form_placeholder_archive_location()}
name="archiveBox"
bind:value={archiveBox}
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"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
</div>
{#if editMode}
<!-- Karton (only in editMode — bulk-editable replace) -->
<div data-testid="description-archive-box">
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_box()}
<FieldLabelBadge variant="replace" />
</label>
<input
id="archiveBox"
type="text"
name="archiveBox"
bind:value={archiveBox}
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"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
</div>
<!-- Mappe (only in editMode — bulk-editable replace) -->
<div data-testid="description-archive-folder">
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_folder()}
<FieldLabelBadge variant="replace" />
</label>
<input
id="archiveFolder"
type="text"
name="archiveFolder"
bind:value={archiveFolder}
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"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
</div>
{/if}
<!-- Mappe -->
<div data-testid="description-archive-folder">
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_folder()}
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
</label>
<input
id="archiveFolder"
type="text"
name="archiveFolder"
bind:value={archiveFolder}
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"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
</div>
</div>
</div>

View File

@@ -21,19 +21,10 @@ describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fen
expect(titleInput.value).toBe('Parent Title');
});
it('pre-fills the documentLocation input from initialDocumentLocation', async () => {
render(DescriptionSection, { initialDocumentLocation: 'Schrank 3, Mappe B' });
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
expect(locationInput.value).toBe('Schrank 3, Mappe B');
});
it('does not stomp a parent-bound documentLocation that is already non-empty', async () => {
render(DescriptionSection, {
documentLocation: 'Bound Value',
initialDocumentLocation: 'Should Not Win'
});
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
expect(locationInput.value).toBe('Bound Value');
it('always renders archiveBox + archiveFolder fields regardless of editMode', async () => {
render(DescriptionSection, { editMode: false });
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
@@ -42,9 +33,25 @@ describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fen
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('hides the editMode-only archive fields when editMode=false', async () => {
render(DescriptionSection, { editMode: false });
expect(document.querySelector('[data-testid="description-archive-box"]')).toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).toBeNull();
it('pre-fills archiveBox from initialArchiveBox when archiveBox is empty', async () => {
render(DescriptionSection, { initialArchiveBox: 'K-03', hideTitle: true });
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
expect(input.value).toBe('K-03');
});
it('pre-fills archiveFolder from initialArchiveFolder when archiveFolder is empty', async () => {
render(DescriptionSection, { initialArchiveFolder: 'Mappe B', hideTitle: true });
const input = document.querySelector('input#archiveFolder') as HTMLInputElement;
expect(input.value).toBe('Mappe B');
});
it('does not stomp a parent-bound archiveBox that is already non-empty', async () => {
render(DescriptionSection, {
archiveBox: 'Parent Value',
initialArchiveBox: 'Should Not Win',
hideTitle: true
});
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
expect(input.value).toBe('Parent Value');
});
});

View File

@@ -207,7 +207,8 @@ async function handleReplaceFile(e: Event) {
bind:tags={tags}
bind:currentTitle={currentTitle}
initialTitle={doc.title ?? ''}
initialDocumentLocation={doc.documentLocation ?? ''}
initialArchiveBox={doc.archiveBox ?? ''}
initialArchiveFolder={doc.archiveFolder ?? ''}
initialSummary={doc.summary ?? ''}
titleRequired={true}
/>

View File

@@ -7,6 +7,7 @@ import * as m from '$lib/paraglide/messages.js';
export type ErrorCode =
| 'PERSON_NOT_FOUND'
| 'ALIAS_NOT_FOUND'
| 'INVALID_PERSON_TYPE'
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
@@ -73,6 +74,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_person_not_found();
case 'ALIAS_NOT_FOUND':
return m.error_alias_not_found();
case 'INVALID_PERSON_TYPE':
return m.error_invalid_person_type();
case 'DOCUMENT_NOT_FOUND':
return m.error_document_not_found();
case 'DOCUMENT_NO_FILE':

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { validatePersonFields } from './person-validation';
describe('validatePersonFields', () => {
it('returns null when all required fields are present for PERSON', () => {
expect(validatePersonFields('PERSON', 'Hans', 'Müller')).toBeNull();
});
it('returns lastName error key when lastName is missing', () => {
expect(validatePersonFields('PERSON', 'Hans', '')).toBe('validation_last_name_required');
});
it('returns lastName error key when lastName is undefined', () => {
expect(validatePersonFields('INSTITUTION', undefined, undefined)).toBe(
'validation_last_name_required'
);
});
it('returns firstName error key when type is PERSON and firstName is missing', () => {
expect(validatePersonFields('PERSON', '', 'Müller')).toBe('validation_first_name_required');
});
it('returns firstName error key when type is PERSON and firstName is undefined', () => {
expect(validatePersonFields('PERSON', undefined, 'Müller')).toBe(
'validation_first_name_required'
);
});
it('returns null for INSTITUTION without firstName', () => {
expect(validatePersonFields('INSTITUTION', undefined, 'Verlag GmbH')).toBeNull();
});
it('returns null for GROUP without firstName', () => {
expect(validatePersonFields('GROUP', undefined, 'Familie Raddatz')).toBeNull();
});
it('returns null for UNKNOWN without firstName', () => {
expect(validatePersonFields('UNKNOWN', undefined, 'Unbekannt')).toBeNull();
});
});

View File

@@ -0,0 +1,39 @@
import { m } from '$lib/paraglide/messages.js';
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
export type PersonType = (typeof PERSON_TYPES)[number];
export type PersonFormData = {
personType?: string | null;
title?: string | null;
firstName?: string | null;
lastName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
notes?: string | null;
};
export function normalizePersonType(raw: string | undefined | null): PersonType {
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
}
export type PersonValidationKey =
| 'validation_last_name_required'
| 'validation_first_name_required';
export function resolveValidationMessage(key: PersonValidationKey): string {
return key === 'validation_last_name_required'
? m.validation_last_name_required()
: m.validation_first_name_required();
}
export function validatePersonFields(
personType: string,
firstName: string | undefined | null,
lastName: string | undefined | null
): PersonValidationKey | null {
if (!lastName) return 'validation_last_name_required';
if (personType === 'PERSON' && !firstName) return 'validation_first_name_required';
return null;
}

View File

@@ -13,14 +13,12 @@ let {
items,
canWrite,
error,
total = 0,
q = '',
sort = 'DATE'
}: {
items: DocumentSearchItem[];
canWrite: boolean;
error?: string | null;
total?: number;
q?: string;
sort?: SortMode;
} = $props();
@@ -71,29 +69,6 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
}
</script>
<!-- DOCUMENT LIST HEADER -->
<div class="mb-2 flex justify-end">
{#if canWrite}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.docs_btn_new()}
</a>
{/if}
</div>
<!-- RESULT COUNT -->
{#if total > 0}
<p class="mb-3 font-sans text-base text-ink-2">{m.docs_result_count({ count: total })}</p>
{/if}
<!-- ERROR -->
{#if error}
<div class="border border-line bg-surface shadow-sm">

View File

@@ -234,28 +234,54 @@ $effect(() => {
onblur={() => (qFocused = false)}
/>
{#if data.canWrite && data.totalElements > 0}
<div class="mb-2 flex flex-col items-end gap-1">
<button
type="button"
onclick={editAllMatching}
disabled={editingAll}
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
data-testid="bulk-edit-all-x"
>
{m.bulk_edit_all_x({ count: data.totalElements })}
</button>
{#if editAllError}
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
{editAllError}
</p>
{/if}
</div>
{/if}
<div class="mb-3 flex items-center justify-between gap-4">
<p class="font-sans text-base text-ink-2">
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
</p>
{#if data.canWrite}
<div class="flex flex-col items-end gap-1">
<div class="flex items-center gap-4">
{#if data.totalElements > 0}
<button
type="button"
onclick={editAllMatching}
disabled={editingAll}
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
data-testid="bulk-edit-all-x"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.bulk_edit_all_x({ count: data.totalElements })}
</button>
{/if}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.docs_btn_new()}
</a>
</div>
{#if editAllError}
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
{editAllError}
</p>
{/if}
</div>
{/if}
</div>
<DocumentList
items={data.items}
total={data.totalElements}
q={data.q}
canWrite={data.canWrite}
error={data.error}

View File

@@ -13,9 +13,12 @@ import { getErrorMessage } from '$lib/errors';
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
let { data } = $props();
const { confirm } = getConfirmService();
const doc = $derived(data.document);
const canWrite = $derived(data.canWrite ?? false);
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
@@ -105,6 +108,26 @@ async function deleteBlock(blockId: string) {
annotationReloadKey++;
}
async function handleAnnotationDeleteRequest(annotationId: string) {
const confirmed = await confirm({
title: m.transcription_block_delete_confirm(),
destructive: true
});
if (!confirmed) return;
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
if (block) {
await deleteBlock(block.id);
} else {
// Annotation has no linked block — delete the annotation directly
const res = await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete annotation failed');
annotationReloadKey++;
}
}
async function reviewToggle(blockId: string) {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
method: 'PUT'
@@ -381,6 +404,7 @@ onMount(() => {
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw}
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
/>
</div>

View File

@@ -85,7 +85,8 @@ export const actions = {
if (doc.title) formData.set('title', doc.title);
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
if (doc.location) formData.set('location', doc.location);
if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation);
if (doc.archiveBox) formData.set('archiveBox', doc.archiveBox);
if (doc.archiveFolder) formData.set('archiveFolder', doc.archiveFolder);
if (doc.transcription) formData.set('transcription', doc.transcription);
if (doc.summary) formData.set('summary', doc.summary);
if (doc.sender?.id) formData.set('senderId', doc.sender.id);

View File

@@ -13,6 +13,7 @@ let {
lastName: string;
displayName: string;
personType?: string | null;
title?: string | null;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
@@ -66,6 +67,14 @@ let {
</div>
</div>
{#if person.personType === 'PERSON' && person.title}
<p
class="mb-0.5 text-center font-sans text-xs tracking-widest text-ink-3 [font-variant:small-caps]"
>
{person.title}
</p>
{/if}
<!-- Name — centered, serif -->
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
{person.displayName}

View File

@@ -1,6 +1,11 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import {
normalizePersonType,
validatePersonFields,
resolveValidationMessage
} from '$lib/person-validation';
export async function load({ params, fetch, locals }) {
const canWrite =
@@ -22,12 +27,16 @@ export async function load({ params, fetch, locals }) {
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data!, aliases: aliasesResult.data ?? [] };
const person = result.data!;
const personType = normalizePersonType(person.personType);
return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] };
}
export const actions = {
update: async ({ request, params, fetch }) => {
const formData = await request.formData();
const personType = normalizePersonType(formData.get('personType')?.toString());
const title = formData.get('title')?.toString().trim() || undefined;
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
@@ -37,15 +46,18 @@ export const actions = {
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
if (!firstName || !lastName) {
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
const validationKey = validatePersonFields(personType, firstName, lastName);
if (validationKey) {
return fail(400, { updateError: resolveValidationMessage(validationKey) });
}
const api = createApiClient(fetch);
const result = await api.PUT('/api/persons/{id}', {
params: { path: { id: params.id } },
body: {
firstName,
personType,
...(title ? { title } : {}),
...(firstName ? { firstName } : {}),
lastName,
...(alias ? { alias } : {}),
...(notes ? { notes } : {}),

View File

@@ -1,93 +1,117 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
import {
PERSON_TYPES as TYPES,
type PersonType,
type PersonFormData
} from '$lib/person-validation';
let {
person
}: {
person: {
firstName?: string | null;
lastName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
notes?: string | null;
};
} = $props();
let { person }: { person: PersonFormData } = $props();
let selectedType = $state<PersonType>(
untrack(() =>
TYPES.includes(person.personType as PersonType) ? (person.personType as PersonType) : 'PERSON'
)
);
const isPerson = $derived(selectedType === 'PERSON');
const lastNameLabel = $derived(
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
? m.form_label_name()
: m.form_label_last_name()
);
const labelCls = 'mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase';
const inputCls =
'block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
</script>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="firstName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_first_name()} *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
value={person.firstName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
<div class="md:col-span-2">
<p class={labelCls}>
{m.form_label_person_type()}
</p>
<PersonTypeSelector
value={selectedType}
name="personType"
onchange={(type: PersonType) => (selectedType = type)}
/>
</div>
<div>
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_last_name()} *</label
>
{#if isPerson}
<div>
<label for="title" class={labelCls}>{m.form_label_title()}</label>
<input
id="title"
name="title"
type="text"
maxlength="50"
value={person.title ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
<input
id="firstName"
name="firstName"
type="text"
required
value={person.firstName ?? ''}
class={inputCls}
/>
</div>
{/if}
<div class={!isPerson ? 'md:col-span-2' : ''}>
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
<input
id="lastName"
name="lastName"
type="text"
required
value={person.lastName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class={inputCls}
/>
</div>
{#if isPerson}
<div class="md:col-span-2">
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
</div>
<div>
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_alias()}</label
>
<input
id="alias"
name="alias"
type="text"
value={person.alias ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label for="birthYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label for="deathYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="md:col-span-2">
<label for="notes" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</label
>
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
<textarea
id="notes"
name="notes"

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { normalizePersonType } from '$lib/person-validation';
describe('edit load — SKIP → UNKNOWN normalization', () => {
it('maps SKIP to UNKNOWN', () => {
expect(normalizePersonType('SKIP')).toBe('UNKNOWN');
});
it('passes PERSON through unchanged', () => {
expect(normalizePersonType('PERSON')).toBe('PERSON');
});
it('passes INSTITUTION through unchanged', () => {
expect(normalizePersonType('INSTITUTION')).toBe('INSTITUTION');
});
it('passes GROUP through unchanged', () => {
expect(normalizePersonType('GROUP')).toBe('GROUP');
});
it('passes UNKNOWN through unchanged', () => {
expect(normalizePersonType('UNKNOWN')).toBe('UNKNOWN');
});
it('defaults null to PERSON', () => {
expect(normalizePersonType(null)).toBe('PERSON');
});
});

View File

@@ -1,5 +1,11 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import {
normalizePersonType,
validatePersonFields,
resolveValidationMessage
} from '$lib/person-validation';
export async function load({ locals }: { locals: App.Locals }) {
const canWrite =
@@ -12,6 +18,8 @@ export async function load({ locals }: { locals: App.Locals }) {
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const personType = normalizePersonType(formData.get('personType')?.toString());
const title = formData.get('title')?.toString().trim() || undefined;
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
@@ -19,8 +27,16 @@ export const actions = {
const deathYearStr = formData.get('deathYear')?.toString().trim();
const notes = formData.get('notes')?.toString().trim() || undefined;
if (!firstName || !lastName) {
return fail(400, { error: 'Vor- und Nachname sind Pflichtfelder.' });
const validationKey = validatePersonFields(personType, firstName, lastName);
if (validationKey) {
return fail(400, {
error: resolveValidationMessage(validationKey),
personType,
title,
firstName: firstName ?? '',
lastName: lastName ?? '',
alias
});
}
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
@@ -29,8 +45,10 @@ export const actions = {
const api = createApiClient(fetch);
const result = await api.POST('/api/persons', {
body: {
firstName,
lastName,
personType,
...(title ? { title } : {}),
...(firstName ? { firstName } : {}),
lastName: lastName!,
...(alias ? { alias } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {}),
@@ -39,7 +57,15 @@ export const actions = {
});
if (!result.response.ok) {
return fail(result.response.status, { error: 'Person konnte nicht gespeichert werden.' });
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, {
error: getErrorMessage(code),
personType,
title,
firstName,
lastName: lastName!,
alias
});
}
throw redirect(303, `/persons/${result.data!.id}`);

View File

@@ -1,11 +1,33 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import BackButton from '$lib/components/BackButton.svelte';
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
let { form } = $props();
let selectedType = $state<PersonType>(
untrack(() =>
TYPES.includes((form?.personType as PersonType) ?? 'PERSON')
? ((form?.personType as PersonType) ?? 'PERSON')
: 'PERSON'
)
);
const isPerson = $derived(selectedType === 'PERSON');
const lastNameLabel = $derived(
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
? m.form_label_name()
: m.form_label_last_name()
);
const inputCls =
'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';
const labelCls = 'mb-1 block text-sm font-medium text-ink-2';
</script>
<div class="mx-auto max-w-2xl px-4 py-8">
<!-- Heading -->
<div class="mb-6">
<BackButton />
<h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
@@ -22,79 +44,92 @@ let { form } = $props();
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<div>
<label for="firstName" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_first_name()} *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
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"
<div class="md:col-span-2">
<p class={labelCls}>{m.form_label_person_type()}</p>
<PersonTypeSelector
value={selectedType}
name="personType"
onchange={(type: PersonType) => (selectedType = type)}
/>
</div>
<div>
<label for="lastName" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_last_name()} *</label
>
{#if isPerson}
<div>
<label for="title" class={labelCls}>{m.form_label_title()}</label>
<input
id="title"
name="title"
type="text"
maxlength="50"
value={form?.title ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
<input
id="firstName"
name="firstName"
type="text"
required
value={form?.firstName ?? ''}
class={inputCls}
/>
</div>
{/if}
<div class={!isPerson ? 'md:col-span-2' : ''}>
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
<input
id="lastName"
name="lastName"
type="text"
required
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"
value={form?.lastName ?? ''}
class={inputCls}
/>
</div>
{#if isPerson}
<div class="md:col-span-2">
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
<input
id="alias"
name="alias"
type="text"
placeholder={m.form_placeholder_alias()}
value={form?.alias ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class={inputCls}
/>
</div>
<div>
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="alias" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_alias()}</label
>
<input
id="alias"
name="alias"
type="text"
placeholder={m.form_placeholder_alias()}
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"
/>
</div>
<div>
<label for="birthYear" class="mb-1 block text-sm font-medium text-ink-2"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
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"
/>
</div>
<div>
<label for="deathYear" class="mb-1 block text-sm font-medium text-ink-2"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
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"
/>
</div>
<div class="md:col-span-2">
<label for="notes" class="mb-1 block text-sm font-medium text-ink-2"
>{m.person_label_notes()}</label
>
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
<textarea
id="notes"
name="notes"
@@ -106,7 +141,6 @@ let { form } = $props();
</div>
</div>
<!-- Save Bar -->
<div
class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
>