fix: address PR review feedback — security, architecture, dead code

Fixes from PR #178 review:

Migration fixes:
- V18/V19: fix FK references from app_users to users (correct table name)
- V18: change annotation_id FK from ON DELETE CASCADE to ON DELETE RESTRICT
  (block is aggregate root, cascade flows from block, not annotation)

Backend fixes:
- TranscriptionService.deleteBlock(): remove userId param, delete block first
  then annotation directly via repository (no ownership check — block owns annotation)
- TranscriptionService.sanitizeText(): remove flawed regex HTML stripping,
  textarea content is plain text by design — just enforce max length
- TranscriptionBlockController.requireUserId(): throw DomainException.unauthorized()
  instead of silently returning null on auth failure
- CreateTranscriptionBlockDTO: add @Min/@Positive validation on coordinates
- Add @Slf4j logging to TranscriptionService for create/delete operations

Frontend fixes:
- Delete DocumentBottomPanel.svelte entirely (issue #175 requirement)
- Remove redundant mode exclusivity $effect (handled at toggle call sites)
- Remove dead handleCommentClick + onCommentClick prop (comments are future work)
- Remove quote hint UI (depends on comment feature)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-05 11:43:35 +02:00
parent 1efd3d8e23
commit 6463a32dfc
9 changed files with 41 additions and 250 deletions

View File

@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
@@ -47,7 +48,7 @@ public class TranscriptionBlockController {
@PathVariable UUID documentId,
@RequestBody CreateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
UUID userId = requireUserId(authentication);
return transcriptionService.createBlock(documentId, dto, userId);
}
@@ -58,7 +59,7 @@ public class TranscriptionBlockController {
@PathVariable UUID blockId,
@RequestBody UpdateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
UUID userId = requireUserId(authentication);
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
}
@@ -67,10 +68,8 @@ public class TranscriptionBlockController {
@RequirePermission(Permission.WRITE_ALL)
public void deleteBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
transcriptionService.deleteBlock(documentId, blockId, userId);
@PathVariable UUID blockId) {
transcriptionService.deleteBlock(documentId, blockId);
}
@PutMapping("/reorder")
@@ -89,14 +88,14 @@ public class TranscriptionBlockController {
return transcriptionService.getBlockHistory(documentId, blockId);
}
private UUID resolveUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
AppUser user = userService.findByUsername(authentication.getName());
return user != null ? user.getId() : null;
} catch (Exception e) {
log.warn("Could not resolve user for transcription: {}", e.getMessage());
return null;
private UUID requireUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
AppUser user = userService.findByUsername(authentication.getName());
if (user == null) {
throw DomainException.unauthorized("User not found");
}
return user.getId();
}
}

View File

@@ -1,5 +1,7 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -8,10 +10,15 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
public class CreateTranscriptionBlockDTO {
@Min(0)
private int pageNumber;
@Min(0)
private double x;
@Min(0)
private double y;
@Positive
private double width;
@Positive
private double height;
private String text;
private String label;

View File

@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
import org.springframework.stereotype.Service;
@@ -30,6 +31,7 @@ public class TranscriptionService {
private final TranscriptionBlockRepository blockRepository;
private final TranscriptionBlockVersionRepository versionRepository;
private final AnnotationRepository annotationRepository;
private final AnnotationService annotationService;
private final DocumentService documentService;
@@ -69,6 +71,7 @@ public class TranscriptionService {
TranscriptionBlock saved = blockRepository.save(block);
saveVersion(saved, userId);
log.info("Created transcription block {} for document {}", saved.getId(), documentId);
return saved;
}
@@ -90,11 +93,17 @@ public class TranscriptionService {
}
@Transactional
public void deleteBlock(UUID documentId, UUID blockId, UUID userId) {
public void deleteBlock(UUID documentId, UUID blockId) {
TranscriptionBlock block = getBlock(documentId, blockId);
// CASCADE deletes annotation, versions, and comments via DB constraints
UUID annotationId = block.getAnnotationId();
// Block is the aggregate root — delete block first (cascades to versions + comments),
// then delete the dependent annotation directly (no ownership check needed)
blockRepository.delete(block);
annotationService.deleteAnnotation(documentId, block.getAnnotationId(), userId);
blockRepository.flush();
annotationRepository.deleteById(annotationId);
log.info("Deleted transcription block {} and annotation {} for document {}",
blockId, annotationId, documentId);
}
@Transactional
@@ -122,13 +131,11 @@ public class TranscriptionService {
versionRepository.save(version);
}
private String sanitizeText(String text) {
String sanitizeText(String text) {
if (text == null) return "";
// Strip any HTML tags — textarea content should be plain text only
String cleaned = text.replaceAll("<[^>]*>", "");
if (cleaned.length() > MAX_TEXT_LENGTH) {
cleaned = cleaned.substring(0, MAX_TEXT_LENGTH);
if (text.length() > MAX_TEXT_LENGTH) {
text = text.substring(0, MAX_TEXT_LENGTH);
}
return cleaned;
return text;
}
}

View File

@@ -1,13 +1,13 @@
CREATE TABLE transcription_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE CASCADE,
annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE RESTRICT,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
text TEXT NOT NULL DEFAULT '',
label VARCHAR(200),
sort_order INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
created_by UUID REFERENCES app_users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES app_users(id) ON DELETE SET NULL,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);

View File

@@ -2,7 +2,7 @@ CREATE TABLE transcription_block_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
text TEXT NOT NULL,
changed_by UUID REFERENCES app_users(id) ON DELETE SET NULL,
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
changed_at TIMESTAMP NOT NULL DEFAULT now()
);