feat(#50): document & annotation comments #57
@@ -0,0 +1,122 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateCommentDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.raddatz.familienarchiv.service.CommentService;
|
||||||
|
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
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CommentController {
|
||||||
|
|
||||||
|
private final CommentService commentService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
// ─── General document comments ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/api/documents/{documentId}/comments")
|
||||||
|
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
|
||||||
|
return commentService.getCommentsForDocument(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/comments")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment postDocumentComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.postComment(documentId, null, dto.getContent(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment replyToDocumentComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Annotation comments ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||||
|
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
|
||||||
|
return commentService.getCommentsForAnnotation(annotationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment postAnnotationComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID annotationId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment replyToAnnotationComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment editComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser currentUser = resolveUser(authentication);
|
||||||
|
return commentService.editComment(documentId, commentId, dto.getContent(), currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser currentUser = resolveUser(authentication);
|
||||||
|
commentService.deleteComment(documentId, commentId, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private AppUser resolveUser(Authentication authentication) {
|
||||||
|
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||||
|
try {
|
||||||
|
return userService.findByUsername(authentication.getName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Could not resolve user for comment: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class CreateCommentDTO {
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ public enum ErrorCode {
|
|||||||
/** The new annotation overlaps an existing one on the same page. 409 */
|
/** The new annotation overlaps an existing one on the same page. 409 */
|
||||||
ANNOTATION_OVERLAP,
|
ANNOTATION_OVERLAP,
|
||||||
|
|
||||||
|
// --- Comments ---
|
||||||
|
/** The comment with the given ID does not exist. 404 */
|
||||||
|
COMMENT_NOT_FOUND,
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
|
|||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE document_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
annotation_id UUID REFERENCES document_annotations(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID REFERENCES document_comments(id) ON DELETE CASCADE,
|
||||||
|
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
author_name VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_dc_document ON document_comments(document_id);
|
||||||
|
CREATE INDEX idx_dc_annotation ON document_comments(annotation_id);
|
||||||
|
CREATE INDEX idx_dc_parent ON document_comments(parent_id);
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CommentService;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(CommentController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class CommentControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean CommentService commentService;
|
||||||
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
|
||||||
|
private static final UUID DOC_ID = UUID.randomUUID();
|
||||||
|
private static final UUID ANN_ID = UUID.randomUUID();
|
||||||
|
private static final UUID COMMENT_ID = UUID.randomUUID();
|
||||||
|
|
||||||
|
// ─── GET /api/documents/{documentId}/comments ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDocumentComments_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentComments_returns200_whenAuthenticated() throws Exception {
|
||||||
|
when(commentService.getCommentsForDocument(any())).thenReturn(List.of());
|
||||||
|
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/{documentId}/comments ────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postDocumentComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void postDocumentComment_returns403_whenMissingPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
|
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.content").value("Test comment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void replyToComment_returns201_whenHasPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||||
|
.authorName("Anna").content("Test comment").build();
|
||||||
|
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/documents/{documentId}/comments/{commentId} ──────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void editComment_returns200_whenHasPermission() throws Exception {
|
||||||
|
DocumentComment updated = DocumentComment.builder()
|
||||||
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
|
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/documents/{documentId}/comments/{commentId} ─────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/{documentId}/annotations/{annId}/comments ─────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAnnotationComments_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getAnnotationComments_returns200_whenAuthenticated() throws Exception {
|
||||||
|
when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of());
|
||||||
|
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void postAnnotationComment_returns403_whenMissingPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void postAnnotationComment_returns201_whenHasPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
|
.authorName("Hans").content("Test comment").build();
|
||||||
|
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void replyToAnnotationComment_returns201_whenHasPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
|
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
||||||
|
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||||
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CommentServiceTest {
|
||||||
|
|
||||||
|
@Mock CommentRepository commentRepository;
|
||||||
|
@InjectMocks CommentService commentService;
|
||||||
|
|
||||||
|
// ─── postComment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_capturesAuthorNameAtWriteTime() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder()
|
||||||
|
.id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("Müller").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_fallsBackToUsername_whenNamesAreBlank() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans42").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_throwsNotFound_whenTargetCommentMissing() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||||
|
|
||||||
|
verify(commentRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_resolvesToRootParent_whenReplyingToAReply() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
UUID replyId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||||
|
DocumentComment existingReply = DocumentComment.builder()
|
||||||
|
.id(replyId).documentId(docId).parentId(rootId).content("Reply1").authorName("Anna").build();
|
||||||
|
|
||||||
|
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
|
||||||
|
|
||||||
|
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_usesDirectComment_whenReplyingToTopLevel() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||||
|
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author);
|
||||||
|
|
||||||
|
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── editComment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editComment_throwsForbidden_whenNotAuthor() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> commentService.editComment(docId, commentId, "Changed", other))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||||
|
|
||||||
|
verify(commentRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editComment_updatesContent_whenAuthor() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID authorId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(authorId).username("hans").build();
|
||||||
|
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(authorId)
|
||||||
|
.content("Original").authorName("Hans").createdAt(created).build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).isEqualTo("Updated");
|
||||||
|
assertThat(result.getCreatedAt()).isEqualTo(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── deleteComment ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteComment_throwsForbidden_whenNotAuthorAndNotAdmin() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> commentService.deleteComment(docId, commentId, other))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||||
|
|
||||||
|
verify(commentRepository, never()).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteComment_succeeds_whenAuthor() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID authorId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(authorId).username("hans").build();
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
|
||||||
|
commentService.deleteComment(docId, commentId, author);
|
||||||
|
|
||||||
|
verify(commentRepository).delete(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteComment_succeeds_whenAdmin() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
AppUser admin = buildAdmin();
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
|
||||||
|
commentService.deleteComment(docId, commentId, admin);
|
||||||
|
|
||||||
|
verify(commentRepository).delete(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getCommentsForDocument ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCommentsForDocument_returnsRootsWithRepliesAttached() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).authorName("Hans").content("Root").build();
|
||||||
|
DocumentComment reply = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build();
|
||||||
|
|
||||||
|
when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId))
|
||||||
|
.thenReturn(List.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply));
|
||||||
|
|
||||||
|
List<DocumentComment> result = commentService.getCommentsForDocument(docId);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private AppUser buildAdmin() {
|
||||||
|
return AppUser.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.username("admin")
|
||||||
|
.groups(Set.of(UserGroup.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.name("admins")
|
||||||
|
.permissions(Set.of("ADMIN"))
|
||||||
|
.build()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,5 +242,13 @@
|
|||||||
"admin_system_backfill_btn": "Jetzt auffüllen",
|
"admin_system_backfill_btn": "Jetzt auffüllen",
|
||||||
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
||||||
"comp_expandable_show_more": "Mehr anzeigen",
|
"comp_expandable_show_more": "Mehr anzeigen",
|
||||||
"comp_expandable_show_less": "Weniger anzeigen"
|
"comp_expandable_show_less": "Weniger anzeigen",
|
||||||
|
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
||||||
|
"comment_section_title": "Diskussion",
|
||||||
|
"comment_placeholder": "Kommentar schreiben…",
|
||||||
|
"comment_btn_post": "Senden",
|
||||||
|
"comment_btn_reply": "Antworten",
|
||||||
|
"comment_edited_label": "· bearbeitet",
|
||||||
|
"comment_panel_title": "Kommentare",
|
||||||
|
"comment_panel_close": "Schließen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,5 +242,13 @@
|
|||||||
"admin_system_backfill_btn": "Backfill now",
|
"admin_system_backfill_btn": "Backfill now",
|
||||||
"admin_system_backfill_success": "{count} documents were backfilled.",
|
"admin_system_backfill_success": "{count} documents were backfilled.",
|
||||||
"comp_expandable_show_more": "Show more",
|
"comp_expandable_show_more": "Show more",
|
||||||
"comp_expandable_show_less": "Show less"
|
"comp_expandable_show_less": "Show less",
|
||||||
|
"error_comment_not_found": "The comment could not be found.",
|
||||||
|
"comment_section_title": "Discussion",
|
||||||
|
"comment_placeholder": "Write a comment…",
|
||||||
|
"comment_btn_post": "Send",
|
||||||
|
"comment_btn_reply": "Reply",
|
||||||
|
"comment_edited_label": "· edited",
|
||||||
|
"comment_panel_title": "Comments",
|
||||||
|
"comment_panel_close": "Close"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,5 +242,13 @@
|
|||||||
"admin_system_backfill_btn": "Completar ahora",
|
"admin_system_backfill_btn": "Completar ahora",
|
||||||
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
||||||
"comp_expandable_show_more": "Mostrar más",
|
"comp_expandable_show_more": "Mostrar más",
|
||||||
"comp_expandable_show_less": "Mostrar menos"
|
"comp_expandable_show_less": "Mostrar menos",
|
||||||
|
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
||||||
|
"comment_section_title": "Discusión",
|
||||||
|
"comment_placeholder": "Escribe un comentario…",
|
||||||
|
"comment_btn_post": "Enviar",
|
||||||
|
"comment_btn_reply": "Responder",
|
||||||
|
"comment_edited_label": "· editado",
|
||||||
|
"comment_panel_title": "Comentarios",
|
||||||
|
"comment_panel_close": "Cerrar"
|
||||||
}
|
}
|
||||||
|
|||||||
90
frontend/src/lib/components/AnnotationCommentPanel.svelte
Normal file
90
frontend/src/lib/components/AnnotationCommentPanel.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documentId: string;
|
||||||
|
annotationId: string;
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCountChange?: (count: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
documentId,
|
||||||
|
annotationId,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
onClose,
|
||||||
|
onCountChange
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-brand-sand bg-white shadow-2xl sm:flex"
|
||||||
|
>
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
||||||
|
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.comment_panel_title()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label={m.comment_panel_close()}
|
||||||
|
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
<CommentThread
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={annotationId}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
loadOnMount={true}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile modal (< sm): fixed full-screen with slide-up sheet -->
|
||||||
|
<div class="fixed inset-0 z-50 flex flex-col sm:hidden">
|
||||||
|
<!-- Semi-transparent backdrop -->
|
||||||
|
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
|
||||||
|
|
||||||
|
<!-- Slide-up panel -->
|
||||||
|
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-white shadow-2xl">
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
||||||
|
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.comment_panel_title()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label={m.comment_panel_close()}
|
||||||
|
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
<CommentThread
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={annotationId}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
loadOnMount={true}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -23,13 +23,17 @@ let {
|
|||||||
canAnnotate,
|
canAnnotate,
|
||||||
color,
|
color,
|
||||||
onDraw,
|
onDraw,
|
||||||
onDelete
|
onDelete,
|
||||||
|
commentCounts,
|
||||||
|
onAnnotationClick
|
||||||
}: {
|
}: {
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
canAnnotate: boolean;
|
canAnnotate: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
|
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
commentCounts?: Record<string, number>;
|
||||||
|
onAnnotationClick?: (id: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let drawStart = $state<{ x: number; y: number } | null>(null);
|
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||||||
@@ -96,6 +100,8 @@ function handlePointerUp(event: PointerEvent) {
|
|||||||
drawRect = null;
|
drawRect = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hoveredId = $state<string | null>(null);
|
||||||
|
|
||||||
const containerStyle = $derived(
|
const containerStyle = $derived(
|
||||||
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}`
|
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}`
|
||||||
);
|
);
|
||||||
@@ -112,14 +118,24 @@ const containerStyle = $derived(
|
|||||||
<div
|
<div
|
||||||
data-testid="annotation-{annotation.id}"
|
data-testid="annotation-{annotation.id}"
|
||||||
data-annotation
|
data-annotation
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Kommentare anzeigen"
|
||||||
|
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
|
||||||
|
onmouseenter={() => (hoveredId = annotation.id)}
|
||||||
|
onmouseleave={() => (hoveredId = null)}
|
||||||
style="
|
style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: {annotation.x * 100}%;
|
left: {annotation.x * 100}%;
|
||||||
top: {annotation.y * 100}%;
|
top: {annotation.y * 100}%;
|
||||||
width: {annotation.width * 100}%;
|
width: {annotation.width * 100}%;
|
||||||
height: {annotation.height * 100}%;
|
height: {annotation.height * 100}%;
|
||||||
background-color: {hexToRgba(annotation.color, 0.3)};
|
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)};
|
||||||
pointer-events: {canAnnotate ? 'auto' : 'none'};
|
box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{#if canAnnotate}
|
{#if canAnnotate}
|
||||||
@@ -127,6 +143,14 @@ const containerStyle = $derived(
|
|||||||
aria-label="Annotation löschen"
|
aria-label="Annotation löschen"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const count = commentCounts?.[annotation.id] ?? 0;
|
||||||
|
if (count > 0) {
|
||||||
|
const msg =
|
||||||
|
count === 1
|
||||||
|
? 'Diese Annotation hat 1 Kommentar. Beim Löschen wird er ebenfalls entfernt. Fortfahren?'
|
||||||
|
: `Diese Annotation hat ${count} Kommentare. Beim Löschen werden sie ebenfalls entfernt. Fortfahren?`;
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
}
|
||||||
onDelete(annotation.id);
|
onDelete(annotation.id);
|
||||||
}}
|
}}
|
||||||
style="
|
style="
|
||||||
@@ -150,6 +174,30 @@ const containerStyle = $derived(
|
|||||||
">×</button
|
">×</button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if (commentCounts?.[annotation.id] ?? 0) > 0}
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
bottom: -10px;
|
||||||
|
right: -10px;
|
||||||
|
background-color: #002850;
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 18px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{commentCounts?.[annotation.id]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
|||||||
394
frontend/src/lib/components/CommentThread.svelte
Normal file
394
frontend/src/lib/components/CommentThread.svelte
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type CommentReply = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Comment = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
replies: CommentReply[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documentId: string;
|
||||||
|
annotationId?: string | null;
|
||||||
|
initialComments?: Comment[];
|
||||||
|
loadOnMount?: boolean;
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
onCountChange?: (count: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
documentId,
|
||||||
|
annotationId = null,
|
||||||
|
initialComments = [],
|
||||||
|
loadOnMount = false,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
onCountChange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||||
|
let newText: string = $state('');
|
||||||
|
let replyingTo: string | null = $state(null);
|
||||||
|
let replyText: string = $state('');
|
||||||
|
let editingId: string | null = $state(null);
|
||||||
|
let editText: string = $state('');
|
||||||
|
let posting: boolean = $state(false);
|
||||||
|
|
||||||
|
const commentsBase = $derived(
|
||||||
|
annotationId
|
||||||
|
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
|
||||||
|
: `/api/documents/${documentId}/comments`
|
||||||
|
);
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return 'gerade eben';
|
||||||
|
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? '' : 'n'}`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
|
||||||
|
return c.updatedAt > c.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canModify(c: { authorId: string | null }): boolean {
|
||||||
|
return (currentUserId != null && c.authorId === currentUserId) || canAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(commentsBase);
|
||||||
|
if (res.ok) {
|
||||||
|
comments = await res.json();
|
||||||
|
const total = comments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||||
|
onCountChange?.(total);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postComment() {
|
||||||
|
const text = newText.trim();
|
||||||
|
if (!text || posting) return;
|
||||||
|
posting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(commentsBase, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: text })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
newText = '';
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postReply(threadId: string) {
|
||||||
|
const text = replyText.trim();
|
||||||
|
if (!text || posting) return;
|
||||||
|
posting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: text })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
replyText = '';
|
||||||
|
replyingTo = null;
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(commentId: string) {
|
||||||
|
const text = editText.trim();
|
||||||
|
if (!text || posting) return;
|
||||||
|
posting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: text })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
editingId = null;
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(commentId: string) {
|
||||||
|
if (posting) return;
|
||||||
|
posting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(comment: Comment | CommentReply) {
|
||||||
|
editingId = comment.id;
|
||||||
|
editText = comment.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReply(threadId: string) {
|
||||||
|
replyingTo = threadId;
|
||||||
|
replyText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelReply() {
|
||||||
|
replyingTo = null;
|
||||||
|
replyText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (loadOnMount) {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each comments as thread, ti (thread.id)}
|
||||||
|
<div class={ti > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
||||||
|
<!-- Root comment -->
|
||||||
|
<div>
|
||||||
|
{#if editingId === thread.id}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
rows={3}
|
||||||
|
bind:value={editText}
|
||||||
|
></textarea>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||||
|
disabled={posting}
|
||||||
|
onclick={() => saveEdit(thread.id)}
|
||||||
|
>
|
||||||
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-sans text-xs font-semibold text-brand-navy"
|
||||||
|
>{thread.authorName}</span
|
||||||
|
>
|
||||||
|
<span class="font-sans text-xs text-gray-400">{timeAgo(thread.createdAt)}</span>
|
||||||
|
{#if wasEdited(thread)}
|
||||||
|
<span class="font-sans text-xs text-gray-400">
|
||||||
|
{m.comment_edited_label()}
|
||||||
|
{timeAgo(thread.updatedAt)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{thread.content}</p>
|
||||||
|
</div>
|
||||||
|
{#if canModify(thread)}
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => startEdit(thread)}
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => deleteComment(thread.id)}
|
||||||
|
>
|
||||||
|
{m.btn_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- Reply button on root comment only if there are no replies -->
|
||||||
|
{#if thread.replies.length === 0 && canComment}
|
||||||
|
<div class="mt-1">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => startReply(thread.id)}
|
||||||
|
>
|
||||||
|
{m.comment_btn_reply()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Replies -->
|
||||||
|
{#each thread.replies as reply, ri (reply.id)}
|
||||||
|
<div class="mt-3 ml-6 border-l-2 border-brand-sand pl-4">
|
||||||
|
{#if editingId === reply.id}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
rows={3}
|
||||||
|
bind:value={editText}
|
||||||
|
></textarea>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||||
|
disabled={posting}
|
||||||
|
onclick={() => saveEdit(reply.id)}
|
||||||
|
>
|
||||||
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-sans text-xs font-semibold text-brand-navy"
|
||||||
|
>{reply.authorName}</span
|
||||||
|
>
|
||||||
|
<span class="font-sans text-xs text-gray-400">{timeAgo(reply.createdAt)}</span>
|
||||||
|
{#if wasEdited(reply)}
|
||||||
|
<span class="font-sans text-xs text-gray-400">
|
||||||
|
{m.comment_edited_label()}
|
||||||
|
{timeAgo(reply.updatedAt)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{reply.content}</p>
|
||||||
|
</div>
|
||||||
|
{#if canModify(reply)}
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => startEdit(reply)}
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => deleteComment(reply.id)}
|
||||||
|
>
|
||||||
|
{m.btn_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- Reply button only on the last reply -->
|
||||||
|
{#if ri === thread.replies.length - 1 && canComment}
|
||||||
|
<div class="mt-1">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => startReply(thread.id)}
|
||||||
|
>
|
||||||
|
{m.comment_btn_reply()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Reply textarea (shown when replyingTo === thread.id) -->
|
||||||
|
{#if replyingTo === thread.id}
|
||||||
|
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
rows={3}
|
||||||
|
placeholder={m.comment_placeholder()}
|
||||||
|
bind:value={replyText}
|
||||||
|
></textarea>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||||
|
disabled={posting}
|
||||||
|
onclick={() => postReply(thread.id)}
|
||||||
|
>
|
||||||
|
{m.comment_btn_post()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={cancelReply}
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- New top-level comment textarea -->
|
||||||
|
{#if canComment}
|
||||||
|
<div class={comments.length > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
rows={3}
|
||||||
|
placeholder={m.comment_placeholder()}
|
||||||
|
bind:value={newText}
|
||||||
|
></textarea>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||||
|
disabled={posting || !newText.trim()}
|
||||||
|
onclick={postComment}
|
||||||
|
>
|
||||||
|
{m.comment_btn_post()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
|
import AnnotationCommentPanel from './AnnotationCommentPanel.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
url,
|
url,
|
||||||
documentId = '',
|
documentId = '',
|
||||||
canAnnotate = false
|
canAnnotate = false,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
canAnnotate?: boolean;
|
canAnnotate?: boolean;
|
||||||
|
canComment?: boolean;
|
||||||
|
currentUserId?: string | null;
|
||||||
|
canAdmin?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||||
@@ -48,6 +56,8 @@ type Annotation = {
|
|||||||
let annotations = $state<Annotation[]>([]);
|
let annotations = $state<Annotation[]>([]);
|
||||||
let annotateMode = $state(false);
|
let annotateMode = $state(false);
|
||||||
let annotateColor = $state('#ffff00');
|
let annotateColor = $state('#ffff00');
|
||||||
|
let commentCounts = new SvelteMap<string, number>();
|
||||||
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||||
@@ -159,11 +169,31 @@ async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCommentCounts(docId: string, anns: Annotation[]) {
|
||||||
|
await Promise.all(
|
||||||
|
anns.map(async (a) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${docId}/annotations/${a.id}/comments`);
|
||||||
|
if (res.ok) {
|
||||||
|
const threads = (await res.json()) as Array<{ replies: unknown[] }>;
|
||||||
|
const total = threads.reduce((sum, t) => sum + 1 + t.replies.length, 0);
|
||||||
|
commentCounts.set(a.id, total);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAnnotations(docId: string) {
|
async function loadAnnotations(docId: string) {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/documents/${docId}/annotations`);
|
const res = await fetch(`/api/documents/${docId}/annotations`);
|
||||||
if (res.ok) annotations = await res.json();
|
if (res.ok) {
|
||||||
|
annotations = await res.json();
|
||||||
|
await loadCommentCounts(docId, annotations);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -187,6 +217,7 @@ async function handleAnnotationDraw(rect: { x: number; y: number; width: number;
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const created: Annotation = await res.json();
|
const created: Annotation = await res.json();
|
||||||
annotations = [...annotations, created];
|
annotations = [...annotations, created];
|
||||||
|
activeAnnotationId = created.id;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -413,10 +444,28 @@ function zoomOut() {
|
|||||||
color={annotateColor}
|
color={annotateColor}
|
||||||
onDraw={handleAnnotationDraw}
|
onDraw={handleAnnotationDraw}
|
||||||
onDelete={handleAnnotationDelete}
|
onDelete={handleAnnotationDelete}
|
||||||
|
commentCounts={Object.fromEntries(commentCounts)}
|
||||||
|
onAnnotationClick={(id) => (activeAnnotationId = id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#key activeAnnotationId}
|
||||||
|
{#if activeAnnotationId}
|
||||||
|
<AnnotationCommentPanel
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={activeAnnotationId}
|
||||||
|
canComment={canComment ?? false}
|
||||||
|
currentUserId={currentUserId ?? null}
|
||||||
|
canAdmin={canAdmin ?? false}
|
||||||
|
onClose={() => (activeAnnotationId = null)}
|
||||||
|
onCountChange={(count) => {
|
||||||
|
if (activeAnnotationId) commentCounts.set(activeAnnotationId, count);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type ErrorCode =
|
|||||||
| 'INVALID_RESET_TOKEN'
|
| 'INVALID_RESET_TOKEN'
|
||||||
| 'ANNOTATION_NOT_FOUND'
|
| 'ANNOTATION_NOT_FOUND'
|
||||||
| 'ANNOTATION_OVERLAP'
|
| 'ANNOTATION_OVERLAP'
|
||||||
|
| 'COMMENT_NOT_FOUND'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
| 'VALIDATION_ERROR'
|
| 'VALIDATION_ERROR'
|
||||||
@@ -67,6 +68,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_annotation_not_found();
|
return m.error_annotation_not_found();
|
||||||
case 'ANNOTATION_OVERLAP':
|
case 'ANNOTATION_OVERLAP':
|
||||||
return m.error_annotation_overlap();
|
return m.error_annotation_overlap();
|
||||||
|
case 'COMMENT_NOT_FOUND':
|
||||||
|
return m.error_comment_not_found();
|
||||||
case 'UNAUTHORIZED':
|
case 'UNAUTHORIZED':
|
||||||
return m.error_unauthorized();
|
return m.error_unauthorized();
|
||||||
case 'FORBIDDEN':
|
case 'FORBIDDEN':
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
const base = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
const result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
const [docResult, commentsRes] = await Promise.all([
|
||||||
|
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||||
|
fetch(`${base}/api/documents/${id}/comments`).catch(() => null)
|
||||||
|
]);
|
||||||
|
|
||||||
if (result.response.status === 401) throw redirect(302, '/login');
|
if (docResult.response.status === 401) throw redirect(302, '/login');
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!docResult.response.ok) {
|
||||||
const code = (result.error as unknown as { code?: string })?.code;
|
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||||
throw error(result.response.status, getErrorMessage(code));
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { document: result.data! };
|
let comments: unknown[] = [];
|
||||||
|
if (commentsRes?.ok) {
|
||||||
|
try {
|
||||||
|
comments = await commentsRes.json();
|
||||||
|
} catch {
|
||||||
|
// ignore invalid response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { document: docResult.data!, comments };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { formatDate } from '$lib/utils/date';
|
|||||||
import { diffWords } from 'diff';
|
import { diffWords } from 'diff';
|
||||||
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
||||||
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||||
|
import CommentThread from '$lib/components/CommentThread.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
|
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||||
|
const canAdmin = $derived(
|
||||||
|
(data.user?.groups as Array<{ permissions: string[] }> | undefined)?.some((g) =>
|
||||||
|
g.permissions.includes('ADMIN')
|
||||||
|
) ?? false
|
||||||
|
);
|
||||||
|
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
||||||
|
|
||||||
let fileUrl = $state('');
|
let fileUrl = $state('');
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
@@ -821,6 +829,24 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. DISKUSSION -->
|
||||||
|
<div>
|
||||||
|
<div class="border-b border-brand-sand pb-2">
|
||||||
|
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.comment_section_title()}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentThread
|
||||||
|
documentId={doc.id}
|
||||||
|
initialComments={data.comments ?? []}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
||||||
<p class="truncate">ID: {doc.id}</p>
|
<p class="truncate">ID: {doc.id}</p>
|
||||||
@@ -875,7 +901,14 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
||||||
<PdfViewer url={fileUrl} documentId={doc.id} canAnnotate={data.canAnnotate} />
|
<PdfViewer
|
||||||
|
url={fileUrl}
|
||||||
|
documentId={doc.id}
|
||||||
|
canAnnotate={data.canAnnotate}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
/>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||||
<img
|
<img
|
||||||
|
|||||||
Reference in New Issue
Block a user