feat(comments): add DocumentComment entity, CommentRepository, and CommentService (green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-24 10:33:39 +01:00
parent 83e5a1fde5
commit 48040dc7e4
4 changed files with 192 additions and 0 deletions

View File

@@ -44,6 +44,10 @@ public enum ErrorCode {
/** The new annotation overlaps an existing one on the same page. 409 */
ANNOTATION_OVERLAP,
// --- Comments ---
/** The comment with the given ID does not exist. 404 */
COMMENT_NOT_FOUND,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,

View File

@@ -0,0 +1,63 @@
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.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "document_comments")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DocumentComment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "document_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID documentId;
@Column(name = "annotation_id")
private UUID annotationId;
@Column(name = "parent_id")
private UUID parentId;
@Column(name = "author_id")
private UUID authorId;
@Column(name = "author_name", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String authorName;
@Column(nullable = false, columnDefinition = "TEXT")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@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;
// Populated by the service — not stored in the database
@Transient
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<DocumentComment> replies = new ArrayList<>();
}

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
List<DocumentComment> findByParentId(UUID parentId);
}

View File

@@ -0,0 +1,109 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
List<DocumentComment> roots =
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
return withReplies(roots);
}
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
return withReplies(roots);
}
@Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.annotationId(annotationId)
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
return commentRepository.save(comment);
}
@Transactional
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
DocumentComment target = commentRepository.findById(commentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
UUID rootId = target.getParentId() != null ? target.getParentId() : target.getId();
DocumentComment root = commentRepository.findById(rootId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + rootId));
DocumentComment reply = DocumentComment.builder()
.documentId(documentId)
.annotationId(root.getAnnotationId())
.parentId(root.getId())
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
return commentRepository.save(reply);
}
@Transactional
public DocumentComment editComment(UUID documentId, UUID commentId, String content, AppUser currentUser) {
DocumentComment comment = findComment(documentId, commentId);
if (!currentUser.getId().equals(comment.getAuthorId())) {
throw DomainException.forbidden("Only the comment author can edit it");
}
comment.setContent(content);
return commentRepository.save(comment);
}
@Transactional
public void deleteComment(UUID documentId, UUID commentId, AppUser currentUser) {
DocumentComment comment = findComment(documentId, commentId);
boolean isAuthor = currentUser.getId().equals(comment.getAuthorId());
boolean isAdmin = currentUser.hasPermission("ADMIN");
if (!isAuthor && !isAdmin) {
throw DomainException.forbidden("Only the comment author or an admin can delete it");
}
commentRepository.delete(comment);
}
// ─── private helpers ──────────────────────────────────────────────────────
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
return roots;
}
private DocumentComment findComment(UUID documentId, UUID commentId) {
return commentRepository.findById(commentId)
.filter(c -> documentId.equals(c.getDocumentId()))
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
}
private String resolveAuthorName(AppUser author) {
String first = author.getFirstName();
String last = author.getLastName();
if ((first == null || first.isBlank()) && (last == null || last.isBlank())) {
return author.getUsername();
}
return ((first != null ? first : "") + " " + (last != null ? last : "")).strip();
}
}