diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java new file mode 100644 index 00000000..a7af973d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java @@ -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 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 reorderBlocks( + @PathVariable UUID documentId, + @RequestBody ReorderTranscriptionBlocksDTO dto) { + return transcriptionService.reorderBlocks(documentId, dto); + } + + @GetMapping("/{blockId}/history") + @RequirePermission(Permission.READ_ALL) + public List 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; + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java new file mode 100644 index 00000000..e1d932c0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java @@ -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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java new file mode 100644 index 00000000..7a7e2efb --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java @@ -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 blockIds; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java new file mode 100644 index 00000000..f0577e6f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java @@ -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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index e40b122c..5952dba5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -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, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java index 26294bb8..d64941ae 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java @@ -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; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java new file mode 100644 index 00000000..6f1e008e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java @@ -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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java new file mode 100644 index 00000000..9a923e04 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java @@ -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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java new file mode 100644 index 00000000..2e6c3365 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java @@ -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 { + + List findByDocumentIdOrderBySortOrderAsc(UUID documentId); + + Optional findByIdAndDocumentId(UUID id, UUID documentId); + + int countByDocumentId(UUID documentId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java new file mode 100644 index 00000000..b4d8399b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java @@ -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 { + + List findByBlockIdOrderByChangedAtDesc(UUID blockId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java new file mode 100644 index 00000000..37440cc2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -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 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 reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) { + List 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 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; + } +}