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 04894717..a26e5e5a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -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, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java new file mode 100644 index 00000000..b93b4244 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java @@ -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 replies = new ArrayList<>(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java new file mode 100644 index 00000000..80269305 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java @@ -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 { + + List findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId); + + List findByAnnotationIdAndParentIdIsNull(UUID annotationId); + + List findByParentId(UUID parentId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java new file mode 100644 index 00000000..84bf9f0b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -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 getCommentsForDocument(UUID documentId) { + List roots = + commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId); + return withReplies(roots); + } + + public List getCommentsForAnnotation(UUID annotationId) { + List 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 withReplies(List 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(); + } +}