feat(transcription): add backend entities, service, and controller

TranscriptionBlock entity with @Version optimistic locking
TranscriptionBlockVersion for edit history
TranscriptionService facade: CRUD, reorder, version history
TranscriptionBlockController: REST endpoints under /api/documents/{docId}/transcription-blocks
DTOs: Create, Update, Reorder
ErrorCode: TRANSCRIPTION_BLOCK_NOT_FOUND, TRANSCRIPTION_BLOCK_CONFLICT
DocumentComment: add block_id field for block-level comment threads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-05 11:13:13 +02:00
parent 5231476c27
commit a46b1a2e84
11 changed files with 423 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
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.model.AppUser;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/documents/{documentId}/transcription-blocks")
@RequiredArgsConstructor
@Slf4j
public class TranscriptionBlockController {
private final TranscriptionService transcriptionService;
private final UserService userService;
@GetMapping
@RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlock> listBlocks(@PathVariable UUID documentId) {
return transcriptionService.listBlocks(documentId);
}
@GetMapping("/{blockId}")
@RequirePermission(Permission.READ_ALL)
public TranscriptionBlock getBlock(@PathVariable UUID documentId, @PathVariable UUID blockId) {
return transcriptionService.getBlock(documentId, blockId);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock createBlock(
@PathVariable UUID documentId,
@RequestBody CreateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
return transcriptionService.createBlock(documentId, dto, userId);
}
@PutMapping("/{blockId}")
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock updateBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@RequestBody UpdateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
}
@DeleteMapping("/{blockId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void deleteBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
transcriptionService.deleteBlock(documentId, blockId, userId);
}
@PutMapping("/reorder")
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> reorderBlocks(
@PathVariable UUID documentId,
@RequestBody ReorderTranscriptionBlocksDTO dto) {
return transcriptionService.reorderBlocks(documentId, dto);
}
@GetMapping("/{blockId}/history")
@RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlockVersion> getBlockHistory(
@PathVariable UUID documentId,
@PathVariable UUID blockId) {
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;
}
}
}

View File

@@ -0,0 +1,18 @@
package org.raddatz.familienarchiv.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateTranscriptionBlockDTO {
private int pageNumber;
private double x;
private double y;
private double width;
private double height;
private String text;
private String label;
}

View File

@@ -0,0 +1,15 @@
package org.raddatz.familienarchiv.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.UUID;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ReorderTranscriptionBlocksDTO {
private List<UUID> blockIds;
}

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateTranscriptionBlockDTO {
private String text;
private String label;
}

View File

@@ -50,6 +50,12 @@ public enum ErrorCode {
/** The new annotation overlaps an existing one on the same page. 409 */
ANNOTATION_OVERLAP,
// --- Transcription Blocks ---
/** The transcription block with the given ID does not exist. 404 */
TRANSCRIPTION_BLOCK_NOT_FOUND,
/** Optimistic locking conflict — block was modified by another user. 409 */
TRANSCRIPTION_BLOCK_CONFLICT,
// --- Comments ---
/** The comment with the given ID does not exist. 404 */
COMMENT_NOT_FOUND,

View File

@@ -33,6 +33,9 @@ public class DocumentComment {
@Column(name = "annotation_id")
private UUID annotationId;
@Column(name = "block_id")
private UUID blockId;
@Column(name = "parent_id")
private UUID parentId;

View File

@@ -0,0 +1,64 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "transcription_blocks")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TranscriptionBlock {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "annotation_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID annotationId;
@Column(name = "document_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID documentId;
@Column(nullable = false, columnDefinition = "TEXT")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String text;
@Column(length = 200)
private String label;
@Column(name = "sort_order", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int sortOrder;
@Version
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int version;
@Column(name = "created_by")
private UUID createdBy;
@Column(name = "updated_by")
private UUID updatedBy;
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
@UpdateTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,39 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "transcription_block_versions")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TranscriptionBlockVersion {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "block_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID blockId;
@Column(nullable = false, columnDefinition = "TEXT")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String text;
@Column(name = "changed_by")
private UUID changedBy;
@Column(name = "changed_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime changedAt;
}

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface TranscriptionBlockRepository extends JpaRepository<TranscriptionBlock, UUID> {
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
int countByDocumentId(UUID documentId);
}

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface TranscriptionBlockVersionRepository extends JpaRepository<TranscriptionBlockVersion, UUID> {
List<TranscriptionBlockVersion> findByBlockIdOrderByChangedAtDesc(UUID blockId);
}

View File

@@ -0,0 +1,134 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
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.exception.ErrorCode;
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.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class TranscriptionService {
private static final String TRANSCRIPTION_COLOR = "#00C7B1";
private static final int MAX_TEXT_LENGTH = 10_000;
private final TranscriptionBlockRepository blockRepository;
private final TranscriptionBlockVersionRepository versionRepository;
private final AnnotationService annotationService;
private final DocumentService documentService;
public List<TranscriptionBlock> listBlocks(UUID documentId) {
return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
}
public TranscriptionBlock getBlock(UUID documentId, UUID blockId) {
return blockRepository.findByIdAndDocumentId(blockId, documentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND,
"Transcription block not found: " + blockId));
}
@Transactional
public TranscriptionBlock createBlock(UUID documentId, CreateTranscriptionBlockDTO dto, UUID userId) {
Document doc = documentService.getDocumentById(documentId);
CreateAnnotationDTO annotationDTO = new CreateAnnotationDTO(
dto.getPageNumber(), dto.getX(), dto.getY(),
dto.getWidth(), dto.getHeight(), TRANSCRIPTION_COLOR);
DocumentAnnotation annotation = annotationService.createAnnotation(
documentId, annotationDTO, userId, doc.getFileHash());
int nextOrder = blockRepository.countByDocumentId(documentId);
String text = sanitizeText(dto.getText());
TranscriptionBlock block = TranscriptionBlock.builder()
.annotationId(annotation.getId())
.documentId(documentId)
.text(text)
.label(dto.getLabel())
.sortOrder(nextOrder)
.createdBy(userId)
.updatedBy(userId)
.build();
TranscriptionBlock saved = blockRepository.save(block);
saveVersion(saved, userId);
return saved;
}
@Transactional
public TranscriptionBlock updateBlock(UUID documentId, UUID blockId,
UpdateTranscriptionBlockDTO dto, UUID userId) {
TranscriptionBlock block = getBlock(documentId, blockId);
String text = sanitizeText(dto.getText());
block.setText(text);
if (dto.getLabel() != null) {
block.setLabel(dto.getLabel());
}
block.setUpdatedBy(userId);
TranscriptionBlock saved = blockRepository.save(block);
saveVersion(saved, userId);
return saved;
}
@Transactional
public void deleteBlock(UUID documentId, UUID blockId, UUID userId) {
TranscriptionBlock block = getBlock(documentId, blockId);
// CASCADE deletes annotation, versions, and comments via DB constraints
blockRepository.delete(block);
annotationService.deleteAnnotation(documentId, block.getAnnotationId(), userId);
}
@Transactional
public List<TranscriptionBlock> reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) {
List<UUID> blockIds = dto.getBlockIds();
for (int i = 0; i < blockIds.size(); i++) {
TranscriptionBlock block = getBlock(documentId, blockIds.get(i));
block.setSortOrder(i);
blockRepository.save(block);
}
return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
}
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
getBlock(documentId, blockId);
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
}
private void saveVersion(TranscriptionBlock block, UUID userId) {
TranscriptionBlockVersion version = TranscriptionBlockVersion.builder()
.blockId(block.getId())
.text(block.getText())
.changedBy(userId)
.build();
versionRepository.save(version);
}
private 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);
}
return cleaned;
}
}