Compare commits
7 Commits
44f495ca8b
...
2bc3b3fb6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc3b3fb6c | ||
|
|
55cf1fb0a4 | ||
|
|
e455efa670 | ||
|
|
1615a4ffa5 | ||
|
|
bc62f3b0af | ||
|
|
420f50b6d5 | ||
|
|
d91a10ef8e |
@@ -39,7 +39,7 @@ public class CommentController {
|
|||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.postComment(documentId, null, dto.getContent(), author);
|
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
||||||
@@ -51,7 +51,7 @@ public class CommentController {
|
|||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Annotation comments ──────────────────────────────────────────────────
|
// ─── Annotation comments ──────────────────────────────────────────────────
|
||||||
@@ -70,7 +70,7 @@ public class CommentController {
|
|||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
|
return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
||||||
@@ -82,7 +82,7 @@ public class CommentController {
|
|||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.Notification;
|
||||||
|
import org.raddatz.familienarchiv.service.NotificationService;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NotificationController {
|
||||||
|
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@GetMapping("/api/notifications")
|
||||||
|
public Page<Notification> getNotifications(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||||
|
return notificationService.getNotifications(user.getId(), pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/notifications/read-all")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void markAllRead(Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
notificationService.markAllRead(user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/api/notifications/{id}/read")
|
||||||
|
public Notification markOneRead(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
return notificationService.markRead(id, user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/users/me/notification-preferences")
|
||||||
|
public NotificationPreferenceDTO getPreferences(Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
return new NotificationPreferenceDTO(user.isNotifyOnReply(), user.isNotifyOnMention());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/users/me/notification-preferences")
|
||||||
|
public NotificationPreferenceDTO updatePreferences(
|
||||||
|
@RequestBody NotificationPreferenceDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
AppUser updated = notificationService.updatePreferences(
|
||||||
|
user.getId(), dto.notifyOnReply(), dto.notifyOnMention());
|
||||||
|
return new NotificationPreferenceDTO(updated.isNotifyOnReply(), updated.isNotifyOnMention());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private AppUser resolveUser(Authentication authentication) {
|
||||||
|
return userService.findByUsername(authentication.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.service.UserSearchService;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserSearchController {
|
||||||
|
|
||||||
|
private final UserSearchService userSearchService;
|
||||||
|
|
||||||
|
@GetMapping("/api/users/search")
|
||||||
|
public List<MentionDTO> search(@RequestParam(defaultValue = "") String q) {
|
||||||
|
return userSearchService.search(q).stream()
|
||||||
|
.map(this::toMentionDTO)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MentionDTO toMentionDTO(AppUser user) {
|
||||||
|
return new MentionDTO(user.getId(), user.getFirstName(), user.getLastName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ package org.raddatz.familienarchiv.dto;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class CreateCommentDTO {
|
public class CreateCommentDTO {
|
||||||
private String content;
|
private String content;
|
||||||
|
private List<UUID> mentionedUserIds = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record MentionDTO(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String firstName,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String lastName
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
public record NotificationPreferenceDTO(boolean notifyOnReply, boolean notifyOnMention) {}
|
||||||
@@ -50,6 +50,10 @@ public enum ErrorCode {
|
|||||||
/** The comment with the given ID does not exist. 404 */
|
/** The comment with the given ID does not exist. 404 */
|
||||||
COMMENT_NOT_FOUND,
|
COMMENT_NOT_FOUND,
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
/** The notification with the given ID does not exist. 404 */
|
||||||
|
NOTIFICATION_NOT_FOUND,
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ public class AppUser {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
|
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private boolean notifyOnReply = false;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private boolean notifyOnMention = false;
|
||||||
|
|
||||||
// Ein User kann in mehreren Gruppen sein
|
// Ein User kann in mehreren Gruppen sein
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
|
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -60,4 +62,21 @@ public class DocumentComment {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private List<DocumentComment> replies = new ArrayList<>();
|
private List<DocumentComment> replies = new ArrayList<>();
|
||||||
|
|
||||||
|
// JPA join table for structured mention references — not serialized directly
|
||||||
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
|
@JoinTable(
|
||||||
|
name = "comment_mentions",
|
||||||
|
joinColumns = @JoinColumn(name = "comment_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "user_id")
|
||||||
|
)
|
||||||
|
@JsonIgnore
|
||||||
|
@Builder.Default
|
||||||
|
private List<AppUser> mentions = new ArrayList<>();
|
||||||
|
|
||||||
|
// Populated by CommentService before serialization — not persisted.
|
||||||
|
@Transient
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private List<MentionDTO> mentionDTOs = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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 = "notifications")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class Notification {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "recipient_id", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private AppUser recipient;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private NotificationType type;
|
||||||
|
|
||||||
|
@Column(name = "document_id")
|
||||||
|
private UUID documentId;
|
||||||
|
|
||||||
|
@Column(name = "reference_id")
|
||||||
|
private UUID referenceId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private boolean read = false;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
// Populated by NotificationService before serialization — not persisted.
|
||||||
|
@Transient
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String actorName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
public enum NotificationType {
|
||||||
|
REPLY,
|
||||||
|
MENTION
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package org.raddatz.familienarchiv.repository;
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -12,4 +15,9 @@ import java.util.UUID;
|
|||||||
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
||||||
Optional<AppUser> findByUsername(String username);
|
Optional<AppUser> findByUsername(String username);
|
||||||
Optional<AppUser> findByEmail(String email);
|
Optional<AppUser> findByEmail(String email);
|
||||||
|
|
||||||
|
@Query("SELECT u FROM AppUser u WHERE " +
|
||||||
|
"LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) " +
|
||||||
|
"OR LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))")
|
||||||
|
List<AppUser> searchByNameOrUsername(@Param("q") String q, Pageable pageable);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.Notification;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
|
||||||
|
|
||||||
|
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
|
||||||
|
|
||||||
|
long countByRecipientIdAndReadFalse(UUID recipientId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
|
||||||
|
void markAllReadByRecipientId(@Param("userId") UUID userId);
|
||||||
|
|
||||||
|
List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -17,20 +19,23 @@ import java.util.UUID;
|
|||||||
public class CommentService {
|
public class CommentService {
|
||||||
|
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
|
private final AppUserRepository userRepository;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||||
List<DocumentComment> roots =
|
List<DocumentComment> roots =
|
||||||
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
||||||
return withReplies(roots);
|
return withRepliesAndMentions(roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
||||||
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
||||||
return withReplies(roots);
|
return withRepliesAndMentions(roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
|
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
|
||||||
|
List<UUID> mentionedUserIds, AppUser author) {
|
||||||
DocumentComment comment = DocumentComment.builder()
|
DocumentComment comment = DocumentComment.builder()
|
||||||
.documentId(documentId)
|
.documentId(documentId)
|
||||||
.annotationId(annotationId)
|
.annotationId(annotationId)
|
||||||
@@ -38,11 +43,16 @@ public class CommentService {
|
|||||||
.authorId(author.getId())
|
.authorId(author.getId())
|
||||||
.authorName(resolveAuthorName(author))
|
.authorName(resolveAuthorName(author))
|
||||||
.build();
|
.build();
|
||||||
return commentRepository.save(comment);
|
saveMentions(comment, mentionedUserIds);
|
||||||
|
DocumentComment saved = commentRepository.save(comment);
|
||||||
|
withMentionDTOs(saved);
|
||||||
|
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
|
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content,
|
||||||
|
List<UUID> mentionedUserIds, AppUser author) {
|
||||||
DocumentComment target = commentRepository.findById(commentId)
|
DocumentComment target = commentRepository.findById(commentId)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
||||||
@@ -60,7 +70,12 @@ public class CommentService {
|
|||||||
.authorId(author.getId())
|
.authorId(author.getId())
|
||||||
.authorName(resolveAuthorName(author))
|
.authorName(resolveAuthorName(author))
|
||||||
.build();
|
.build();
|
||||||
return commentRepository.save(reply);
|
saveMentions(reply, mentionedUserIds);
|
||||||
|
DocumentComment saved = commentRepository.save(reply);
|
||||||
|
withMentionDTOs(saved);
|
||||||
|
notificationService.notifyReply(saved, root);
|
||||||
|
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -86,11 +101,29 @@ public class CommentService {
|
|||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
|
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
|
||||||
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
|
roots.forEach(root -> {
|
||||||
|
List<DocumentComment> replies = commentRepository.findByParentId(root.getId());
|
||||||
|
replies.forEach(this::withMentionDTOs);
|
||||||
|
root.setReplies(replies);
|
||||||
|
withMentionDTOs(root);
|
||||||
|
});
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) {
|
||||||
|
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
|
||||||
|
List<AppUser> users = userRepository.findAllById(mentionedUserIds);
|
||||||
|
comment.setMentions(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void withMentionDTOs(DocumentComment comment) {
|
||||||
|
List<MentionDTO> dtos = comment.getMentions().stream()
|
||||||
|
.map(u -> new MentionDTO(u.getId(), u.getFirstName(), u.getLastName()))
|
||||||
|
.toList();
|
||||||
|
comment.setMentionDTOs(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
private DocumentComment findComment(UUID documentId, UUID commentId) {
|
private DocumentComment findComment(UUID documentId, UUID commentId) {
|
||||||
return commentRepository.findById(commentId)
|
return commentRepository.findById(commentId)
|
||||||
.filter(c -> documentId.equals(c.getDocumentId()))
|
.filter(c -> documentId.equals(c.getDocumentId()))
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
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.model.Notification;
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.mail.MailException;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
private final NotificationRepository notificationRepository;
|
||||||
|
private final CommentRepository commentRepository;
|
||||||
|
private final AppUserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private JavaMailSender mailSender;
|
||||||
|
|
||||||
|
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
||||||
|
private String mailFrom;
|
||||||
|
|
||||||
|
@Value("${app.base-url:http://localhost:3000}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates REPLY notifications for all participants in the thread that the given reply belongs to,
|
||||||
|
* excluding the replier themselves.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void notifyReply(DocumentComment reply, DocumentComment root) {
|
||||||
|
Set<UUID> participantIds = collectParticipantIds(root);
|
||||||
|
participantIds.remove(reply.getAuthorId());
|
||||||
|
|
||||||
|
for (UUID participantId : participantIds) {
|
||||||
|
Optional<AppUser> recipientOpt = userRepository.findById(participantId);
|
||||||
|
if (recipientOpt.isEmpty()) continue;
|
||||||
|
|
||||||
|
AppUser recipient = recipientOpt.get();
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.recipient(recipient)
|
||||||
|
.type(NotificationType.REPLY)
|
||||||
|
.documentId(reply.getDocumentId())
|
||||||
|
.referenceId(reply.getId())
|
||||||
|
.build();
|
||||||
|
notificationRepository.save(notification);
|
||||||
|
|
||||||
|
if (recipient.isNotifyOnReply()) {
|
||||||
|
sendNotificationEmail(recipient, reply, NotificationType.REPLY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates MENTION notifications for each mentioned user.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) {
|
||||||
|
for (UUID mentionedUserId : mentionedUserIds) {
|
||||||
|
Optional<AppUser> recipientOpt = userRepository.findById(mentionedUserId);
|
||||||
|
if (recipientOpt.isEmpty()) continue;
|
||||||
|
|
||||||
|
AppUser recipient = recipientOpt.get();
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.recipient(recipient)
|
||||||
|
.type(NotificationType.MENTION)
|
||||||
|
.documentId(comment.getDocumentId())
|
||||||
|
.referenceId(comment.getId())
|
||||||
|
.build();
|
||||||
|
notificationRepository.save(notification);
|
||||||
|
|
||||||
|
if (recipient.isNotifyOnMention()) {
|
||||||
|
sendNotificationEmail(recipient, comment, NotificationType.MENTION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<Notification> getNotifications(UUID userId, Pageable pageable) {
|
||||||
|
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countUnread(UUID userId) {
|
||||||
|
return notificationRepository.countByRecipientIdAndReadFalse(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void markAllRead(UUID userId) {
|
||||||
|
notificationRepository.markAllReadByRecipientId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Notification markRead(UUID notificationId, UUID userId) {
|
||||||
|
Notification notification = notificationRepository.findById(notificationId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId));
|
||||||
|
if (!notification.getRecipient().getId().equals(userId)) {
|
||||||
|
throw DomainException.forbidden("Notification belongs to a different user");
|
||||||
|
}
|
||||||
|
notification.setRead(true);
|
||||||
|
return notificationRepository.save(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
|
||||||
|
AppUser user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "User not found: " + userId));
|
||||||
|
user.setNotifyOnReply(notifyOnReply);
|
||||||
|
user.setNotifyOnMention(notifyOnMention);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Set<UUID> collectParticipantIds(DocumentComment root) {
|
||||||
|
Set<UUID> ids = new LinkedHashSet<>();
|
||||||
|
if (root.getAuthorId() != null) ids.add(root.getAuthorId());
|
||||||
|
|
||||||
|
commentRepository.findByParentId(root.getId())
|
||||||
|
.forEach(reply -> {
|
||||||
|
if (reply.getAuthorId() != null) ids.add(reply.getAuthorId());
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
|
||||||
|
sb.append("?commentId=").append(comment.getId());
|
||||||
|
if (comment.getAnnotationId() != null) {
|
||||||
|
sb.append("&annotationId=").append(comment.getAnnotationId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendNotificationEmail(AppUser recipient, DocumentComment comment, NotificationType type) {
|
||||||
|
if (mailSender == null) {
|
||||||
|
log.warn("Mail sender not configured — skipping notification email to {}", recipient.getEmail());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (recipient.getEmail() == null || recipient.getEmail().isBlank()) return;
|
||||||
|
|
||||||
|
StringBuilder path = new StringBuilder("/documents/").append(comment.getDocumentId());
|
||||||
|
buildCommentPath(comment, path);
|
||||||
|
String link = baseUrl + path;
|
||||||
|
|
||||||
|
String subject = type == NotificationType.REPLY
|
||||||
|
? "Neue Antwort auf deinen Kommentar — Familienarchiv"
|
||||||
|
: "Du wurdest in einem Kommentar erwähnt — Familienarchiv";
|
||||||
|
|
||||||
|
String body = type == NotificationType.REPLY
|
||||||
|
? "Hallo,\n\njemand hat auf einen Kommentar geantwortet, an dem du beteiligt warst.\n\n"
|
||||||
|
+ "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team"
|
||||||
|
: "Hallo,\n\njemand hat dich in einem Kommentar erwähnt.\n\n"
|
||||||
|
+ "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team";
|
||||||
|
|
||||||
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
|
message.setFrom(mailFrom);
|
||||||
|
message.setTo(recipient.getEmail());
|
||||||
|
message.setSubject(subject);
|
||||||
|
message.setText(body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
mailSender.send(message);
|
||||||
|
} catch (MailException e) {
|
||||||
|
log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserSearchService {
|
||||||
|
|
||||||
|
private static final int MAX_RESULTS = 10;
|
||||||
|
|
||||||
|
private final AppUserRepository userRepository;
|
||||||
|
|
||||||
|
public List<AppUser> search(String query) {
|
||||||
|
if (query == null || query.isBlank()) return List.of();
|
||||||
|
return userRepository.searchByNameOrUsername(query.trim(), PageRequest.of(0, MAX_RESULTS));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Notification preferences on the user record — no separate entity needed
|
||||||
|
ALTER TABLE users ADD COLUMN notify_on_reply BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE users ADD COLUMN notify_on_mention BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- In-app notifications
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
recipient_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION'
|
||||||
|
document_id UUID,
|
||||||
|
reference_id UUID, -- commentId that triggered this notification
|
||||||
|
read BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE comment_mentions (
|
||||||
|
comment_id UUID NOT NULL REFERENCES document_comments(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (comment_id, user_id)
|
||||||
|
);
|
||||||
@@ -81,7 +81,7 @@ class CommentControllerTest {
|
|||||||
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
@@ -104,7 +104,7 @@ class CommentControllerTest {
|
|||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||||
.authorName("Anna").content("Test comment").build();
|
.authorName("Anna").content("Test comment").build();
|
||||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
@@ -179,7 +179,7 @@ class CommentControllerTest {
|
|||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
.authorName("Hans").content("Test comment").build();
|
.authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
@@ -194,7 +194,7 @@ class CommentControllerTest {
|
|||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
||||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.Notification;
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.NotificationService;
|
||||||
|
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.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
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.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@WebMvcTest(NotificationController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class NotificationControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean NotificationService notificationService;
|
||||||
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
private static final UUID USER_ID = UUID.randomUUID();
|
||||||
|
|
||||||
|
// ─── GET /api/notifications ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser")
|
||||||
|
void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
Notification n = Notification.builder()
|
||||||
|
.id(UUID.randomUUID()).recipient(user)
|
||||||
|
.type(NotificationType.REPLY).read(false).build();
|
||||||
|
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(n), PageRequest.of(0, 10), 1));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser")
|
||||||
|
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(notificationService).getNotifications(eq(USER_ID), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/notifications/read-all ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/notifications/read-all"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser")
|
||||||
|
void markAllRead_returns204_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/notifications/read-all"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
verify(notificationService).markAllRead(USER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/notifications/{id}/read ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser")
|
||||||
|
void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
UUID notifId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
org.mockito.Mockito.doThrow(
|
||||||
|
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
||||||
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/users/me/notification-preferences ──────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPreferences_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser")
|
||||||
|
void getPreferences_returnsCurrentPreferences() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(true).notifyOnMention(false).build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.notifyOnReply").value(true))
|
||||||
|
.andExpect(jsonPath("$.notifyOnMention").value(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PUT /api/users/me/notification-preferences ──────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser")
|
||||||
|
void updatePreferences_persistsBothBooleans() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
|
||||||
|
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(true).notifyOnMention(true).build();
|
||||||
|
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.notifyOnReply").value(true))
|
||||||
|
.andExpect(jsonPath("$.notifyOnMention").value(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.UserSearchService;
|
||||||
|
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.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.anyString;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(UserSearchController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class UserSearchControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean UserSearchService userSearchService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
||||||
|
.firstName("Hans").lastName("Mueller").username("hans").build();
|
||||||
|
when(userSearchService.search("Hans")).thenReturn(List.of(user));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returnsEmptyList_whenQueryIsEmpty() throws Exception {
|
||||||
|
when(userSearchService.search("")).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", ""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returnsAtMostTenResults() throws Exception {
|
||||||
|
when(userSearchService.search(anyString())).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", "a"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
|||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
import org.raddatz.familienarchiv.model.UserGroup;
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -20,6 +21,7 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -30,6 +32,8 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
|
|||||||
class CommentServiceTest {
|
class CommentServiceTest {
|
||||||
|
|
||||||
@Mock CommentRepository commentRepository;
|
@Mock CommentRepository commentRepository;
|
||||||
|
@Mock AppUserRepository userRepository;
|
||||||
|
@Mock NotificationService notificationService;
|
||||||
@InjectMocks CommentService commentService;
|
@InjectMocks CommentService commentService;
|
||||||
|
|
||||||
// ─── postComment ──────────────────────────────────────────────────────────
|
// ─── postComment ──────────────────────────────────────────────────────────
|
||||||
@@ -43,7 +47,7 @@ class CommentServiceTest {
|
|||||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||||
}
|
}
|
||||||
@@ -56,7 +60,7 @@ class CommentServiceTest {
|
|||||||
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
||||||
}
|
}
|
||||||
@@ -70,7 +74,7 @@ class CommentServiceTest {
|
|||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
|
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||||
|
|
||||||
@@ -95,7 +99,7 @@ class CommentServiceTest {
|
|||||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
|
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||||
}
|
}
|
||||||
@@ -114,11 +118,30 @@ class CommentServiceTest {
|
|||||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author);
|
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_triggersNotification_afterSave() {
|
||||||
|
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();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
|
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
|
||||||
|
|
||||||
|
verify(notificationService).notifyReply(eq(saved), eq(root));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── editComment ──────────────────────────────────────────────────────────
|
// ─── editComment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.*;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
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.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class NotificationServiceTest {
|
||||||
|
|
||||||
|
@Mock NotificationRepository notificationRepository;
|
||||||
|
@Mock CommentRepository commentRepository;
|
||||||
|
@Mock AppUserRepository userRepository;
|
||||||
|
@Mock JavaMailSender mailSender;
|
||||||
|
|
||||||
|
@InjectMocks NotificationService notificationService;
|
||||||
|
|
||||||
|
private AppUser userA;
|
||||||
|
private AppUser userB;
|
||||||
|
private AppUser userC;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
// mailSender is @Autowired(required=false) — not in the @RequiredArgsConstructor
|
||||||
|
// constructor, so Mockito won't inject it automatically. Inject explicitly.
|
||||||
|
ReflectionTestUtils.setField(notificationService, "mailSender", mailSender);
|
||||||
|
|
||||||
|
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
|
||||||
|
.firstName("Anna").lastName("Smith").email("a@test.com")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
userB = AppUser.builder().id(UUID.randomUUID()).username("userB")
|
||||||
|
.firstName("Bob").lastName("Jones").email("b@test.com")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
userC = AppUser.builder().id(UUID.randomUUID()).username("userC")
|
||||||
|
.firstName("Clara").lastName("Doe").email("c@test.com")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── notifyReply ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_createsNotificationForThreadParticipant() {
|
||||||
|
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||||
|
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
|
||||||
|
|
||||||
|
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply));
|
||||||
|
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||||
|
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, root);
|
||||||
|
|
||||||
|
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
||||||
|
verify(notificationRepository, times(2)).save(captor.capture());
|
||||||
|
|
||||||
|
List<Notification> saved = captor.getAllValues();
|
||||||
|
assertThat(saved).extracting(n -> n.getRecipient().getId())
|
||||||
|
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||||
|
assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY);
|
||||||
|
assertThat(saved).allMatch(n -> !n.isRead());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_doesNotNotifyTheReplierThemselves() {
|
||||||
|
// userA is both a thread participant and the replier
|
||||||
|
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userA.getId());
|
||||||
|
|
||||||
|
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(reply));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, root);
|
||||||
|
|
||||||
|
verify(notificationRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_deduplicatesParticipants() {
|
||||||
|
// userB has posted twice in the thread — should get exactly one notification
|
||||||
|
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||||
|
DocumentComment first = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
||||||
|
DocumentComment second = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
|
||||||
|
|
||||||
|
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(first, second, reply));
|
||||||
|
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||||
|
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, root);
|
||||||
|
|
||||||
|
// userA (root) + userB (deduplicated) = 2 notifications, not 3
|
||||||
|
verify(notificationRepository, times(2)).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() {
|
||||||
|
userA.setNotifyOnReply(true);
|
||||||
|
userB.setNotifyOnReply(false);
|
||||||
|
|
||||||
|
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||||
|
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
|
||||||
|
|
||||||
|
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply));
|
||||||
|
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||||
|
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, root);
|
||||||
|
|
||||||
|
// Only userA has email enabled — one email sent
|
||||||
|
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── notifyMentions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyMentions_createsNotificationPerMentionedUser() {
|
||||||
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
|
||||||
|
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||||
|
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||||
|
|
||||||
|
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
||||||
|
verify(notificationRepository, times(2)).save(captor.capture());
|
||||||
|
|
||||||
|
List<Notification> saved = captor.getAllValues();
|
||||||
|
assertThat(saved).extracting(n -> n.getRecipient().getId())
|
||||||
|
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||||
|
assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyMentions_sendsEmailOnlyToUsersWithMentionNotificationsEnabled() {
|
||||||
|
userA.setNotifyOnMention(true);
|
||||||
|
userB.setNotifyOnMention(false);
|
||||||
|
|
||||||
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
|
||||||
|
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||||
|
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||||
|
|
||||||
|
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── markRead ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() {
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.recipient(userA)
|
||||||
|
.type(NotificationType.REPLY)
|
||||||
|
.read(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(notificationRepository.findById(notification.getId())).thenReturn(Optional.of(notification));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> notificationService.markRead(notification.getId(), userB.getId()))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("different user");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId) {
|
||||||
|
return DocumentComment.builder()
|
||||||
|
.id(id)
|
||||||
|
.documentId(UUID.randomUUID())
|
||||||
|
.parentId(parentId)
|
||||||
|
.authorId(authorId)
|
||||||
|
.authorName("Author")
|
||||||
|
.content("content")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -294,5 +294,16 @@
|
|||||||
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
|
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
|
||||||
"enrich_back_to_list": "Zurück zur Liste",
|
"enrich_back_to_list": "Zurück zur Liste",
|
||||||
"comment_empty_hint": "Noch keine Kommentare – starte die Diskussion!",
|
"comment_empty_hint": "Noch keine Kommentare – starte die Diskussion!",
|
||||||
"comment_start_discussion": "Diskussion starten →"
|
"comment_start_discussion": "Diskussion starten →",
|
||||||
|
"notification_bell_label": "Benachrichtigungen",
|
||||||
|
"notification_bell_unread_label": "{count} ungelesene Benachrichtigungen",
|
||||||
|
"notification_mark_all_read": "Alle gelesen",
|
||||||
|
"notification_empty": "Keine neuen Benachrichtigungen",
|
||||||
|
"notification_type_reply": "{actor} hat auf deinen Kommentar geantwortet",
|
||||||
|
"notification_type_mention": "{actor} hat dich in einem Kommentar erwähnt",
|
||||||
|
"notification_prefs_heading": "Benachrichtigungen",
|
||||||
|
"notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet",
|
||||||
|
"notification_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt",
|
||||||
|
"mention_btn_label": "Person erwähnen",
|
||||||
|
"mention_popup_empty": "Keine Nutzer gefunden"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,5 +294,16 @@
|
|||||||
"enrich_done_body": "All documents have been processed.",
|
"enrich_done_body": "All documents have been processed.",
|
||||||
"enrich_back_to_list": "Back to list",
|
"enrich_back_to_list": "Back to list",
|
||||||
"comment_empty_hint": "No comments yet – start the discussion!",
|
"comment_empty_hint": "No comments yet – start the discussion!",
|
||||||
"comment_start_discussion": "Start discussion →"
|
"comment_start_discussion": "Start discussion →",
|
||||||
|
"notification_bell_label": "Notifications",
|
||||||
|
"notification_bell_unread_label": "{count} unread notifications",
|
||||||
|
"notification_mark_all_read": "Mark all read",
|
||||||
|
"notification_empty": "No new notifications",
|
||||||
|
"notification_type_reply": "{actor} replied to your comment",
|
||||||
|
"notification_type_mention": "{actor} mentioned you in a comment",
|
||||||
|
"notification_prefs_heading": "Notifications",
|
||||||
|
"notification_pref_reply": "Email when someone replies to my comment",
|
||||||
|
"notification_pref_mention": "Email when someone mentions me in a comment",
|
||||||
|
"mention_btn_label": "Mention person",
|
||||||
|
"mention_popup_empty": "No users found"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,5 +294,16 @@
|
|||||||
"enrich_done_body": "Todos los documentos han sido procesados.",
|
"enrich_done_body": "Todos los documentos han sido procesados.",
|
||||||
"enrich_back_to_list": "Volver a la lista",
|
"enrich_back_to_list": "Volver a la lista",
|
||||||
"comment_empty_hint": "Aún no hay comentarios – ¡inicia la discusión!",
|
"comment_empty_hint": "Aún no hay comentarios – ¡inicia la discusión!",
|
||||||
"comment_start_discussion": "Iniciar discusión →"
|
"comment_start_discussion": "Iniciar discusión →",
|
||||||
|
"notification_bell_label": "Notificaciones",
|
||||||
|
"notification_bell_unread_label": "{count} notificaciones sin leer",
|
||||||
|
"notification_mark_all_read": "Marcar todo como leído",
|
||||||
|
"notification_empty": "No hay notificaciones nuevas",
|
||||||
|
"notification_type_reply": "{actor} respondió a tu comentario",
|
||||||
|
"notification_type_mention": "{actor} te mencionó en un comentario",
|
||||||
|
"notification_prefs_heading": "Notificaciones",
|
||||||
|
"notification_pref_reply": "Correo cuando alguien responde a mi comentario",
|
||||||
|
"notification_pref_mention": "Correo cuando alguien me menciona en un comentario",
|
||||||
|
"mention_btn_label": "Mencionar persona",
|
||||||
|
"mention_popup_empty": "No se encontraron usuarios"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { Comment, CommentReply } from '$lib/types';
|
import type { Comment, CommentReply } from '$lib/types';
|
||||||
|
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||||
|
import { renderBody, extractContent } from '$lib/utils/mention';
|
||||||
|
import type { MentionDTO } from '$lib/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -11,6 +14,7 @@ type Props = {
|
|||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
|
targetCommentId?: string | null;
|
||||||
onCountChange?: (count: number) => void;
|
onCountChange?: (count: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,16 +26,21 @@ let {
|
|||||||
canComment,
|
canComment,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
canAdmin,
|
canAdmin,
|
||||||
|
targetCommentId = null,
|
||||||
onCountChange
|
onCountChange
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||||
|
let highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
|
||||||
let newText: string = $state('');
|
let newText: string = $state('');
|
||||||
let replyingTo: string | null = $state(null);
|
let replyingTo: string | null = $state(null);
|
||||||
let replyText: string = $state('');
|
let replyText: string = $state('');
|
||||||
let editingId: string | null = $state(null);
|
let editingId: string | null = $state(null);
|
||||||
let editText: string = $state('');
|
let editText: string = $state('');
|
||||||
let posting: boolean = $state(false);
|
let posting: boolean = $state(false);
|
||||||
|
let newMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
let replyMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
let editMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
|
||||||
const commentsBase = $derived(
|
const commentsBase = $derived(
|
||||||
annotationId
|
annotationId
|
||||||
@@ -76,13 +85,15 @@ async function postComment() {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
|
||||||
const res = await fetch(commentsBase, {
|
const res = await fetch(commentsBase, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
newText = '';
|
newText = '';
|
||||||
|
newMentionCandidates = [];
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -95,13 +106,15 @@ async function postReply(threadId: string) {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
|
||||||
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
replyText = '';
|
replyText = '';
|
||||||
|
replyMentionCandidates = [];
|
||||||
replyingTo = null;
|
replyingTo = null;
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
@@ -115,13 +128,15 @@ async function saveEdit(commentId: string) {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
|
||||||
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
editingId = null;
|
editingId = null;
|
||||||
|
editMentionCandidates = [];
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -147,6 +162,7 @@ async function deleteComment(commentId: string) {
|
|||||||
function startEdit(comment: Comment | CommentReply) {
|
function startEdit(comment: Comment | CommentReply) {
|
||||||
editingId = comment.id;
|
editingId = comment.id;
|
||||||
editText = comment.content;
|
editText = comment.content;
|
||||||
|
editMentionCandidates = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
@@ -171,6 +187,25 @@ onMount(() => {
|
|||||||
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||||
onCountChange?.(total);
|
onCountChange?.(total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetCommentId) {
|
||||||
|
// Scroll to target after a tick so the DOM is settled
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Remove highlight on first user interaction
|
||||||
|
const clearHighlight = () => {
|
||||||
|
highlightedCommentId = null;
|
||||||
|
document.removeEventListener('click', clearHighlight, true);
|
||||||
|
document.removeEventListener('keydown', clearHighlight, true);
|
||||||
|
document.removeEventListener('scroll', clearHighlight, true);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', clearHighlight, true);
|
||||||
|
document.addEventListener('keydown', clearHighlight, true);
|
||||||
|
document.addEventListener('scroll', clearHighlight, true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -181,11 +216,13 @@ onMount(() => {
|
|||||||
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
||||||
{#if editingId === comment.id}
|
{#if editingId === comment.id}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
|
||||||
rows={3}
|
|
||||||
bind:value={editText}
|
bind:value={editText}
|
||||||
></textarea>
|
bind:mentionCandidates={editMentionCandidates}
|
||||||
|
rows={3}
|
||||||
|
disabled={posting}
|
||||||
|
onsubmit={() => saveEdit(comment.id)}
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||||
@@ -215,7 +252,10 @@ onMount(() => {
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{comment.content}</p>
|
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||||
|
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if canModify(comment)}
|
{#if canModify(comment)}
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
@@ -269,13 +309,23 @@ onMount(() => {
|
|||||||
{#each comments as thread, ti (thread.id)}
|
{#each comments as thread, ti (thread.id)}
|
||||||
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
||||||
<!-- Root comment -->
|
<!-- Root comment -->
|
||||||
<div>
|
<div
|
||||||
|
data-comment-id={thread.id}
|
||||||
|
class={highlightedCommentId === thread.id
|
||||||
|
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||||
|
: ''}
|
||||||
|
>
|
||||||
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Replies -->
|
<!-- Replies -->
|
||||||
{#each thread.replies as reply, ri (reply.id)}
|
{#each thread.replies as reply, ri (reply.id)}
|
||||||
<div class="mt-3 ml-6 border-l-2 border-line pl-4">
|
<div
|
||||||
|
data-comment-id={reply.id}
|
||||||
|
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
|
||||||
|
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -283,12 +333,14 @@ onMount(() => {
|
|||||||
<!-- Reply compose box -->
|
<!-- Reply compose box -->
|
||||||
{#if replyingTo === thread.id}
|
{#if replyingTo === thread.id}
|
||||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
bind:value={replyText}
|
||||||
|
bind:mentionCandidates={replyMentionCandidates}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={replyText}
|
disabled={posting}
|
||||||
></textarea>
|
onsubmit={() => postReply(thread.id)}
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||||
@@ -313,12 +365,14 @@ onMount(() => {
|
|||||||
{#if canComment}
|
{#if canComment}
|
||||||
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
bind:value={newText}
|
||||||
|
bind:mentionCandidates={newMentionCandidates}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={newText}
|
disabled={posting}
|
||||||
></textarea>
|
onsubmit={postComment}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Props = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
height: number;
|
height: number;
|
||||||
activeTab: DocumentPanelTab;
|
activeTab: DocumentPanelTab;
|
||||||
|
targetCommentId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -38,7 +39,8 @@ let {
|
|||||||
canAdmin,
|
canAdmin,
|
||||||
open = $bindable(),
|
open = $bindable(),
|
||||||
height = $bindable(),
|
height = $bindable(),
|
||||||
activeTab = $bindable()
|
activeTab = $bindable(),
|
||||||
|
targetCommentId = null
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||||
@@ -180,6 +182,7 @@ function handleCountChange(count: number) {
|
|||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetCommentId}
|
||||||
onCountChange={handleCountChange}
|
onCountChange={handleCountChange}
|
||||||
/>
|
/>
|
||||||
{:else if activeTab === 'history'}
|
{:else if activeTab === 'history'}
|
||||||
|
|||||||
235
frontend/src/lib/components/MentionEditor.svelte
Normal file
235
frontend/src/lib/components/MentionEditor.svelte
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { detectMention } from '$lib/utils/mention';
|
||||||
|
import type { MentionDTO } from '$lib/types';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
mentionCandidates: MentionDTO[];
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onsubmit?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
mentionCandidates = $bindable([]),
|
||||||
|
placeholder = '',
|
||||||
|
rows = 3,
|
||||||
|
disabled = false,
|
||||||
|
onsubmit
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let query: string | null = $state(null);
|
||||||
|
let results: MentionDTO[] = $state([]);
|
||||||
|
let highlightedIndex = $state(0);
|
||||||
|
let mentionStart = $state(0);
|
||||||
|
|
||||||
|
let textarea: HTMLTextAreaElement | null = null;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function attachTextarea(node: HTMLTextAreaElement) {
|
||||||
|
textarea = node;
|
||||||
|
return () => {
|
||||||
|
textarea = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
if (!textarea) return;
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const detected = detectMention(value, cursorPos);
|
||||||
|
|
||||||
|
if (detected === null) {
|
||||||
|
closePopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate where the @ starts
|
||||||
|
const before = value.slice(0, cursorPos);
|
||||||
|
const atIndex = before.lastIndexOf('@');
|
||||||
|
mentionStart = atIndex;
|
||||||
|
|
||||||
|
if (query !== detected) {
|
||||||
|
query = detected;
|
||||||
|
highlightedIndex = 0;
|
||||||
|
scheduleSearch(detected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSearch(q: string) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
if (!q) {
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data: MentionDTO[] = await res.json();
|
||||||
|
results = data.slice(0, 5);
|
||||||
|
} else {
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(user: MentionDTO) {
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const displayName = `${user.firstName} ${user.lastName}`;
|
||||||
|
// Replace @partialQuery with @FirstName LastName (plus trailing space)
|
||||||
|
const replacement = `@${displayName} `;
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const before = value.slice(0, mentionStart);
|
||||||
|
const after = value.slice(cursorPos);
|
||||||
|
value = before + replacement + after;
|
||||||
|
|
||||||
|
// Deduplicate and add to candidates
|
||||||
|
if (!mentionCandidates.some((c) => c.id === user.id)) {
|
||||||
|
mentionCandidates = [...mentionCandidates, user];
|
||||||
|
}
|
||||||
|
|
||||||
|
closePopup();
|
||||||
|
|
||||||
|
// Reposition cursor after the inserted mention
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!textarea) return;
|
||||||
|
const pos = mentionStart + replacement.length;
|
||||||
|
textarea.selectionStart = pos;
|
||||||
|
textarea.selectionEnd = pos;
|
||||||
|
textarea.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopup() {
|
||||||
|
query = null;
|
||||||
|
results = [];
|
||||||
|
highlightedIndex = 0;
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.ctrlKey && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
onsubmit?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query === null) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length > 0) {
|
||||||
|
highlightedIndex = (highlightedIndex + 1) % results.length;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length > 0) {
|
||||||
|
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && results.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectUser(results[highlightedIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAtButtonClick() {
|
||||||
|
if (!textarea) return;
|
||||||
|
const pos = textarea.selectionStart;
|
||||||
|
const before = value.slice(0, pos);
|
||||||
|
const after = value.slice(pos);
|
||||||
|
// Ensure @ is preceded by whitespace or is at the start
|
||||||
|
const needsSpace = before.length > 0 && !/\s$/.test(before);
|
||||||
|
const insertion = needsSpace ? ' @' : '@';
|
||||||
|
value = before + insertion + after;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!textarea) return;
|
||||||
|
const newPos = pos + insertion.length;
|
||||||
|
textarea.selectionStart = newPos;
|
||||||
|
textarea.selectionEnd = newPos;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
// Trigger mention detection after inserting @
|
||||||
|
const detected = detectMention(value, newPos);
|
||||||
|
if (detected !== null) {
|
||||||
|
mentionStart = newPos - 1;
|
||||||
|
query = detected;
|
||||||
|
highlightedIndex = 0;
|
||||||
|
scheduleSearch(detected);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const popupOpen = $derived(query !== null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<textarea
|
||||||
|
{@attach attachTextarea}
|
||||||
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||||
|
rows={rows}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
bind:value={value}
|
||||||
|
oninput={handleInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
{#if popupOpen}
|
||||||
|
<div
|
||||||
|
class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={m.mention_btn_label()}
|
||||||
|
>
|
||||||
|
{#if results.length === 0}
|
||||||
|
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
|
||||||
|
{:else}
|
||||||
|
{#each results as user, i (user.id)}
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlightedIndex}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
// Use mousedown to fire before textarea blur
|
||||||
|
e.preventDefault();
|
||||||
|
selectUser(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.firstName}
|
||||||
|
{user.lastName}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.mention_btn_label()}
|
||||||
|
disabled={disabled}
|
||||||
|
class="mt-1 rounded border border-line px-2 py-0.5 font-sans text-xs font-medium text-ink-3 transition-colors hover:border-ink hover:text-ink disabled:opacity-40"
|
||||||
|
onclick={handleAtButtonClick}
|
||||||
|
>
|
||||||
|
@
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
304
frontend/src/lib/components/NotificationBell.svelte
Normal file
304
frontend/src/lib/components/NotificationBell.svelte
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { PUBLIC_NOTIFICATION_POLL_MS } from '$env/static/public';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type NotificationItem = {
|
||||||
|
id: string;
|
||||||
|
type: 'REPLY' | 'MENTION';
|
||||||
|
documentId: string;
|
||||||
|
referenceId: string;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
actorName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let notifications: NotificationItem[] = $state([]);
|
||||||
|
let unreadCount = $derived(notifications.filter((n) => !n.read).length);
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
// DOM refs managed via attachments
|
||||||
|
let bellButtonEl: HTMLButtonElement | null = null;
|
||||||
|
let firstFocusableEl: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
|
const pollMs = Number(PUBLIC_NOTIFICATION_POLL_MS) || 60000;
|
||||||
|
let intervalId: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
async function fetchNotifications() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications?size=10');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
notifications = data.content ?? [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch notifications', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDropdown() {
|
||||||
|
open = !open;
|
||||||
|
if (open) {
|
||||||
|
await fetchNotifications();
|
||||||
|
// defer focus until DOM updates
|
||||||
|
setTimeout(() => {
|
||||||
|
firstFocusableEl?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
open = false;
|
||||||
|
bellButtonEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(notification: NotificationItem) {
|
||||||
|
if (!notification.read) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
||||||
|
notification.read = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mark notification as read', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
||||||
|
closeDropdown();
|
||||||
|
goto(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||||
|
for (const n of notifications) {
|
||||||
|
n.read = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mark all notifications as read', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && open) {
|
||||||
|
event.stopPropagation();
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment: stores the element reference for the bell button
|
||||||
|
function attachBellButton(node: HTMLButtonElement) {
|
||||||
|
bellButtonEl = node;
|
||||||
|
return () => {
|
||||||
|
bellButtonEl = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment: stores the element reference for the first focusable element in the dropdown
|
||||||
|
function attachFirstFocusable(node: HTMLButtonElement) {
|
||||||
|
firstFocusableEl = node;
|
||||||
|
return () => {
|
||||||
|
firstFocusableEl = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment: closes dropdown when clicking outside the wrapper element
|
||||||
|
function attachClickOutside(node: HTMLElement) {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||||
|
if (open) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handleClick, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClick, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(isoString: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(isoString).getTime();
|
||||||
|
const diffMs = now - then;
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMin < 1) return 'gerade eben';
|
||||||
|
if (diffMin < 60) return `vor ${diffMin} Min.`;
|
||||||
|
const diffH = Math.floor(diffMin / 60);
|
||||||
|
if (diffH < 24) return `vor ${diffH} Std.`;
|
||||||
|
const diffD = Math.floor(diffH / 24);
|
||||||
|
return `vor ${diffD} Tag${diffD !== 1 ? 'en' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
intervalId = setInterval(fetchNotifications, pollMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="relative" {@attach attachClickOutside}>
|
||||||
|
<!-- Bell button -->
|
||||||
|
<button
|
||||||
|
{@attach attachBellButton}
|
||||||
|
type="button"
|
||||||
|
onclick={toggleDropdown}
|
||||||
|
aria-label={unreadCount > 0
|
||||||
|
? m.notification_bell_unread_label({ count: unreadCount })
|
||||||
|
: m.notification_bell_label()}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
class="relative rounded-sm p-2 text-ink-2 transition-colors hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<!-- Bell SVG -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Unread badge -->
|
||||||
|
{#if unreadCount > 0}
|
||||||
|
<span
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg"
|
||||||
|
>
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={m.notification_bell_label()}
|
||||||
|
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-line px-4 py-3">
|
||||||
|
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.notification_bell_label()}
|
||||||
|
</span>
|
||||||
|
{#if notifications.length > 0}
|
||||||
|
<button
|
||||||
|
{@attach attachFirstFocusable}
|
||||||
|
type="button"
|
||||||
|
onclick={markAllRead}
|
||||||
|
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.notification_mark_all_read()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification list -->
|
||||||
|
{#if notifications.length === 0}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 text-ink-3 opacity-40"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{m.notification_empty()}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul role="list">
|
||||||
|
{#each notifications as notification (notification.id)}
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => markRead(notification)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && markRead(notification)}
|
||||||
|
class="flex cursor-pointer items-start gap-3 border-b border-line px-4 py-3 last:border-b-0 hover:bg-canvas
|
||||||
|
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||||
|
>
|
||||||
|
<!-- Type icon -->
|
||||||
|
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||||
|
{#if notification.type === 'REPLY'}
|
||||||
|
<!-- Reply icon -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Mention icon -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Text + time -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm leading-snug text-ink">
|
||||||
|
{notification.type === 'REPLY'
|
||||||
|
? m.notification_type_reply({ actor: notification.actorName })
|
||||||
|
: m.notification_type_mention({ actor: notification.actorName })}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unread dot -->
|
||||||
|
{#if !notification.read}
|
||||||
|
<span
|
||||||
|
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||||
|
aria-label="ungelesen"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -8,11 +8,19 @@ type Props = {
|
|||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
|
targetCommentId?: string | null;
|
||||||
onCountChange?: (count: number) => void;
|
onCountChange?: (count: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props =
|
let {
|
||||||
$props();
|
documentId,
|
||||||
|
initialComments,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
targetCommentId = null,
|
||||||
|
onCountChange
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
@@ -22,6 +30,7 @@ let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountC
|
|||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetCommentId}
|
||||||
onCountChange={onCountChange}
|
onCountChange={onCountChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
export type MentionDTO = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CommentReply = {
|
export type CommentReply = {
|
||||||
id: string;
|
id: string;
|
||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
@@ -5,6 +11,7 @@ export type CommentReply = {
|
|||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
mentionDTOs?: MentionDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
@@ -15,6 +22,7 @@ export type Comment = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
replies: CommentReply[];
|
replies: CommentReply[];
|
||||||
|
mentionDTOs?: MentionDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||||
|
|||||||
120
frontend/src/lib/utils/mention.spec.ts
Normal file
120
frontend/src/lib/utils/mention.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { detectMention, extractContent, renderBody } from './mention';
|
||||||
|
import type { MentionDTO } from '$lib/types';
|
||||||
|
|
||||||
|
// ─── detectMention ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('detectMention', () => {
|
||||||
|
it('returns null when text has no @', () => {
|
||||||
|
expect(detectMention('hello world', 11)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when @ is not the most recent trigger word', () => {
|
||||||
|
// cursor is past a completed mention (next word started)
|
||||||
|
expect(detectMention('hello @Hans Müller more', 22)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string immediately after @', () => {
|
||||||
|
expect(detectMention('hello @', 7)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns query text after @', () => {
|
||||||
|
expect(detectMention('hello @Han', 10)).toBe('Han');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when @ is preceded by a letter (email address pattern)', () => {
|
||||||
|
expect(detectMention('user@example', 12)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns query for @ at the very start of string', () => {
|
||||||
|
expect(detectMention('@Hans', 5)).toBe('Hans');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when cursor is before the @', () => {
|
||||||
|
expect(detectMention('@Hans', 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── extractContent ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('extractContent', () => {
|
||||||
|
it('returns empty arrays for empty string', () => {
|
||||||
|
const result = extractContent('', []);
|
||||||
|
expect(result.content).toBe('');
|
||||||
|
expect(result.mentionedUserIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plain content unchanged when no candidates', () => {
|
||||||
|
const result = extractContent('Hello world', []);
|
||||||
|
expect(result.content).toBe('Hello world');
|
||||||
|
expect(result.mentionedUserIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts user id when @FirstName LastName is in content', () => {
|
||||||
|
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||||
|
const result = extractContent('Hey @Hans Müller how are you?', candidates);
|
||||||
|
expect(result.mentionedUserIds).toContain('uuid-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates user ids when same user mentioned twice', () => {
|
||||||
|
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||||
|
const result = extractContent('@Hans Müller and @Hans Müller again', candidates);
|
||||||
|
expect(result.mentionedUserIds).toHaveLength(1);
|
||||||
|
expect(result.mentionedUserIds).toContain('uuid-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects multiple distinct users', () => {
|
||||||
|
const candidates: MentionDTO[] = [
|
||||||
|
{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' },
|
||||||
|
{ id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' }
|
||||||
|
];
|
||||||
|
const result = extractContent('@Hans Müller and @Anna Schmidt', candidates);
|
||||||
|
expect(result.mentionedUserIds).toContain('uuid-1');
|
||||||
|
expect(result.mentionedUserIds).toContain('uuid-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── renderBody ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('renderBody', () => {
|
||||||
|
it('returns escaped plain text when no mentions', () => {
|
||||||
|
expect(renderBody('Hello world', [])).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes < and > in content', () => {
|
||||||
|
const result = renderBody('<script>alert(1)</script>', []);
|
||||||
|
expect(result).toContain('<script>');
|
||||||
|
expect(result).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes & in content', () => {
|
||||||
|
const result = renderBody('AT&T', []);
|
||||||
|
expect(result).toContain('AT&T');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps @mention in an anchor tag', () => {
|
||||||
|
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||||
|
const result = renderBody('Hey @Hans Müller!', mentions);
|
||||||
|
expect(result).toContain('<a');
|
||||||
|
expect(result).toContain('Hans Müller');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not double-encode already escaped text', () => {
|
||||||
|
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||||
|
const result = renderBody('Check @Hans Müller', mentions);
|
||||||
|
expect(result).not.toContain('&');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces all occurrences of the same mention', () => {
|
||||||
|
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||||
|
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
|
||||||
|
const linkCount = (result.match(/<a /g) ?? []).length;
|
||||||
|
expect(linkCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts newlines to <br>', () => {
|
||||||
|
const result = renderBody('line1\nline2', []);
|
||||||
|
expect(result).toContain('<br>');
|
||||||
|
expect(result).not.toContain('\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/lib/utils/mention.ts
Normal file
67
frontend/src/lib/utils/mention.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { MentionDTO } from '$lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the current textarea value and cursor position, returns the
|
||||||
|
* @-mention query being typed (the text after the last triggering @),
|
||||||
|
* or null if no mention is active.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - @ must be preceded by whitespace or be at the start of the string
|
||||||
|
* - The text between @ and the cursor must not contain a space (a
|
||||||
|
* completed mention word already has a space)
|
||||||
|
*/
|
||||||
|
export function detectMention(text: string, cursorPos: number): string | null {
|
||||||
|
const before = text.slice(0, cursorPos);
|
||||||
|
const atIndex = before.lastIndexOf('@');
|
||||||
|
if (atIndex === -1) return null;
|
||||||
|
|
||||||
|
// @ must be at start or preceded by whitespace
|
||||||
|
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
|
||||||
|
|
||||||
|
const query = before.slice(atIndex + 1);
|
||||||
|
// If the query contains a space the user has moved past the trigger word
|
||||||
|
if (query.includes(' ')) return null;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the raw textarea value and a list of candidate users (from the
|
||||||
|
* mention popup selections), returns the plain content string and the
|
||||||
|
* de-duplicated list of mentioned user IDs.
|
||||||
|
*/
|
||||||
|
export function extractContent(
|
||||||
|
text: string,
|
||||||
|
candidates: MentionDTO[]
|
||||||
|
): { content: string; mentionedUserIds: string[] } {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const user of candidates) {
|
||||||
|
const displayName = `${user.firstName} ${user.lastName}`.trim();
|
||||||
|
if (text.includes(`@${displayName}`)) {
|
||||||
|
seen.add(user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { content: text, mentionedUserIds: [...seen] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a comment body as safe HTML:
|
||||||
|
* 1. Escapes all HTML-special characters in the raw content
|
||||||
|
* 2. Replaces every @FirstName LastName occurrence with an anchor link
|
||||||
|
* 3. Converts newlines to <br>
|
||||||
|
*/
|
||||||
|
export function renderBody(content: string, mentions: MentionDTO[]): string {
|
||||||
|
let escaped = content
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
|
||||||
|
for (const mention of mentions) {
|
||||||
|
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
||||||
|
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;
|
||||||
|
escaped = escaped.replaceAll(`@${displayName}`, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
return escaped.replaceAll('\n', '<br>');
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { page } from '$app/state';
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
|
import NotificationBell from '$lib/components/NotificationBell.svelte';
|
||||||
import AppNav from './AppNav.svelte';
|
import AppNav from './AppNav.svelte';
|
||||||
import UserMenu from './UserMenu.svelte';
|
import UserMenu from './UserMenu.svelte';
|
||||||
|
|
||||||
@@ -52,6 +53,11 @@ const userInitials = $derived.by(() => {
|
|||||||
<!-- Theme toggle -->
|
<!-- Theme toggle -->
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
|
<!-- Notification bell (authenticated users only) -->
|
||||||
|
{#if data?.user}
|
||||||
|
<NotificationBell />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- User menu -->
|
<!-- User menu -->
|
||||||
<UserMenu userInitials={userInitials} />
|
<UserMenu userInitials={userInitials} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
||||||
@@ -8,6 +9,8 @@ import type { DocumentPanelTab } from '$lib/types';
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
const targetCommentId = $derived(page.url.searchParams.get('commentId'));
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||||
const canAdmin = $derived(
|
const canAdmin = $derived(
|
||||||
@@ -92,7 +95,11 @@ onMount(() => {
|
|||||||
if (!isNaN(h) && h >= 80) panelHeight = h;
|
if (!isNaN(h) && h >= 80) panelHeight = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedOpen === 'true') {
|
if (targetCommentId) {
|
||||||
|
// Deep-link: always open discussion tab regardless of saved state
|
||||||
|
panelOpen = true;
|
||||||
|
activeTab = 'discussion';
|
||||||
|
} else if (savedOpen === 'true') {
|
||||||
panelOpen = true;
|
panelOpen = true;
|
||||||
} else if (savedOpen === null && !doc?.filePath) {
|
} else if (savedOpen === null && !doc?.filePath) {
|
||||||
// No prior state and no file — open to metadata so the panel is immediately useful.
|
// No prior state and no file — open to metadata so the panel is immediately useful.
|
||||||
@@ -175,6 +182,7 @@ $effect(() => {
|
|||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetCommentId}
|
||||||
bind:open={panelOpen}
|
bind:open={panelOpen}
|
||||||
bind:height={panelHeight}
|
bind:height={panelHeight}
|
||||||
bind:activeTab={activeTab}
|
bind:activeTab={activeTab}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
const apiBase = () => env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
return { user: locals.user };
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||||
|
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`);
|
||||||
|
const notificationPrefs = res.ok ? await res.json() : null;
|
||||||
|
return { user: locals.user, notificationPrefs };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -50,5 +55,26 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { passwordSuccess: true };
|
return { passwordSuccess: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNotificationPrefs: async ({ request, fetch }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const body = {
|
||||||
|
notifyOnReply: formData.get('notifyOnReply') === 'true',
|
||||||
|
notifyOnMention: formData.get('notifyOnMention') === 'true'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return fail(res.status, { prefsError: getErrorMessage(data?.code) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { prefsSuccess: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import PersonalInfoForm from './PersonalInfoForm.svelte';
|
import PersonalInfoForm from './PersonalInfoForm.svelte';
|
||||||
import PasswordChangeForm from './PasswordChangeForm.svelte';
|
import PasswordChangeForm from './PasswordChangeForm.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
let notifyOnReply = $state(untrack(() => data.notificationPrefs?.notifyOnReply ?? false));
|
||||||
|
let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMention ?? false));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
@@ -30,4 +35,54 @@ let { data, form } = $props();
|
|||||||
<PersonalInfoForm user={data.user} form={form} />
|
<PersonalInfoForm user={data.user} form={form} />
|
||||||
<PasswordChangeForm form={form} />
|
<PasswordChangeForm form={form} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification preferences -->
|
||||||
|
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.notification_prefs_heading()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if form?.prefsSuccess}
|
||||||
|
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||||
|
{m.profile_saved()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.prefsError}
|
||||||
|
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{form.prefsError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" action="?/updateNotificationPrefs" use:enhance>
|
||||||
|
<input type="hidden" name="notifyOnReply" value={notifyOnReply} />
|
||||||
|
<input type="hidden" name="notifyOnMention" value={notifyOnMention} />
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<label class="flex cursor-pointer items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={notifyOnReply}
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-ink">{m.notification_pref_reply()}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={notifyOnMention}
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-ink">{m.notification_pref_mention()}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user