From 2bc865fefd5c471aba7e8fd9ec0d4f389e619d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Thu, 4 Dec 2025 08:31:56 +0100 Subject: [PATCH] Notification System --- .../fitpub/controller/CommentController.java | 6 + .../fitpub/controller/LikeController.java | 6 + .../controller/NotificationController.java | 159 ++++++++++ .../fitpub/model/dto/NotificationDTO.java | 58 ++++ .../fitpub/model/entity/Notification.java | 158 ++++++++++ .../repository/NotificationRepository.java | 69 +++++ .../fitpub/service/NotificationService.java | 282 ++++++++++++++++++ .../V9__create_notifications_table.sql | 39 +++ 8 files changed, 777 insertions(+) create mode 100644 src/main/java/org/operaton/fitpub/controller/NotificationController.java create mode 100644 src/main/java/org/operaton/fitpub/model/dto/NotificationDTO.java create mode 100644 src/main/java/org/operaton/fitpub/model/entity/Notification.java create mode 100644 src/main/java/org/operaton/fitpub/repository/NotificationRepository.java create mode 100644 src/main/java/org/operaton/fitpub/service/NotificationService.java create mode 100644 src/main/resources/db/migration/V9__create_notifications_table.sql diff --git a/src/main/java/org/operaton/fitpub/controller/CommentController.java b/src/main/java/org/operaton/fitpub/controller/CommentController.java index 87ae3c5..979108d 100644 --- a/src/main/java/org/operaton/fitpub/controller/CommentController.java +++ b/src/main/java/org/operaton/fitpub/controller/CommentController.java @@ -12,6 +12,7 @@ import org.operaton.fitpub.repository.ActivityRepository; import org.operaton.fitpub.repository.CommentRepository; import org.operaton.fitpub.repository.UserRepository; import org.operaton.fitpub.service.FederationService; +import org.operaton.fitpub.service.NotificationService; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -42,6 +43,7 @@ public class CommentController { private final ActivityRepository activityRepository; private final UserRepository userRepository; private final FederationService federationService; + private final NotificationService notificationService; @Value("${fitpub.base-url}") private String baseUrl; @@ -130,6 +132,10 @@ public class CommentController { log.info("User {} commented on activity {}", user.getUsername(), activityId); + // Create notification for activity owner + String commenterActorUri = user.getActorUri(baseUrl); + notificationService.createActivityCommentedNotification(activity, saved, commenterActorUri); + // Send ActivityPub Create/Note activity to followers if activity is public if (activity.getVisibility() == Activity.Visibility.PUBLIC || activity.getVisibility() == Activity.Visibility.FOLLOWERS) { diff --git a/src/main/java/org/operaton/fitpub/controller/LikeController.java b/src/main/java/org/operaton/fitpub/controller/LikeController.java index 3d920dd..1fc4526 100644 --- a/src/main/java/org/operaton/fitpub/controller/LikeController.java +++ b/src/main/java/org/operaton/fitpub/controller/LikeController.java @@ -10,6 +10,7 @@ import org.operaton.fitpub.repository.ActivityRepository; import org.operaton.fitpub.repository.LikeRepository; import org.operaton.fitpub.repository.UserRepository; import org.operaton.fitpub.service.FederationService; +import org.operaton.fitpub.service.NotificationService; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -39,6 +40,7 @@ public class LikeController { private final ActivityRepository activityRepository; private final UserRepository userRepository; private final FederationService federationService; + private final NotificationService notificationService; @Value("${fitpub.base-url}") private String baseUrl; @@ -111,6 +113,10 @@ public class LikeController { log.info("User {} liked activity {}", user.getUsername(), activityId); + // Create notification for activity owner + String likerActorUri = user.getActorUri(baseUrl); + notificationService.createActivityLikedNotification(activity, likerActorUri); + // Send ActivityPub Like activity to followers if activity is public if (activity.getVisibility() == Activity.Visibility.PUBLIC) { String activityUri = baseUrl + "/activities/" + activityId; diff --git a/src/main/java/org/operaton/fitpub/controller/NotificationController.java b/src/main/java/org/operaton/fitpub/controller/NotificationController.java new file mode 100644 index 0000000..feadc0f --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/NotificationController.java @@ -0,0 +1,159 @@ +package org.operaton.fitpub.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.dto.NotificationDTO; +import org.operaton.fitpub.model.entity.Notification; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.service.NotificationService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * REST controller for notification operations. + */ +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +@Slf4j +public class NotificationController { + + private final NotificationService notificationService; + private final UserRepository userRepository; + + /** + * Helper method to get user from authenticated UserDetails. + */ + private User getUser(UserDetails userDetails) { + return userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + } + + /** + * Get all notifications for the authenticated user. + * + * @param userDetails the authenticated user + * @param pageable pagination parameters + * @return page of notifications + */ + @GetMapping + public ResponseEntity> getNotifications( + @AuthenticationPrincipal UserDetails userDetails, + Pageable pageable + ) { + User user = getUser(userDetails); + Page notifications = notificationService.getNotifications(user.getId(), pageable); + Page notificationDTOs = notifications.map(NotificationDTO::fromEntity); + return ResponseEntity.ok(notificationDTOs); + } + + /** + * Get unread notifications for the authenticated user. + * + * @param userDetails the authenticated user + * @param pageable pagination parameters + * @return page of unread notifications + */ + @GetMapping("/unread") + public ResponseEntity> getUnreadNotifications( + @AuthenticationPrincipal UserDetails userDetails, + Pageable pageable + ) { + User user = getUser(userDetails); + Page notifications = notificationService.getUnreadNotifications(user.getId(), pageable); + Page notificationDTOs = notifications.map(NotificationDTO::fromEntity); + return ResponseEntity.ok(notificationDTOs); + } + + /** + * Get count of unread notifications. + * + * @param userDetails the authenticated user + * @return count of unread notifications + */ + @GetMapping("/unread/count") + public ResponseEntity> getUnreadCount( + @AuthenticationPrincipal UserDetails userDetails + ) { + User user = getUser(userDetails); + long count = notificationService.countUnreadNotifications(user.getId()); + Map response = new HashMap<>(); + response.put("count", count); + return ResponseEntity.ok(response); + } + + /** + * Mark a notification as read. + * + * @param notificationId the notification ID + * @param userDetails the authenticated user + * @return no content + */ + @PutMapping("/{notificationId}/read") + @Transactional + public ResponseEntity markAsRead( + @PathVariable UUID notificationId, + @AuthenticationPrincipal UserDetails userDetails + ) { + User user = getUser(userDetails); + boolean success = notificationService.markAsRead(notificationId, user.getId()); + + if (!success) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.noContent().build(); + } + + /** + * Mark all notifications as read. + * + * @param userDetails the authenticated user + * @return number of notifications marked as read + */ + @PutMapping("/read-all") + @Transactional + public ResponseEntity> markAllAsRead( + @AuthenticationPrincipal UserDetails userDetails + ) { + User user = getUser(userDetails); + int count = notificationService.markAllAsRead(user.getId()); + Map response = new HashMap<>(); + response.put("count", count); + return ResponseEntity.ok(response); + } + + /** + * Delete a notification. + * + * @param notificationId the notification ID + * @param userDetails the authenticated user + * @return no content + */ + @DeleteMapping("/{notificationId}") + @Transactional + public ResponseEntity deleteNotification( + @PathVariable UUID notificationId, + @AuthenticationPrincipal UserDetails userDetails + ) { + User user = getUser(userDetails); + boolean success = notificationService.deleteNotification(notificationId, user.getId()); + + if (!success) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/NotificationDTO.java b/src/main/java/org/operaton/fitpub/model/dto/NotificationDTO.java new file mode 100644 index 0000000..910dc8f --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/NotificationDTO.java @@ -0,0 +1,58 @@ +package org.operaton.fitpub.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.operaton.fitpub.model.entity.Notification; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * DTO for Notification data transfer. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationDTO { + + private UUID id; + private String type; + private String actorUri; + private String actorDisplayName; + private String actorUsername; + private String actorAvatarUrl; + private UUID activityId; + private String activityTitle; + private UUID commentId; + private String commentText; + private boolean read; + private LocalDateTime createdAt; + private LocalDateTime readAt; + + /** + * Creates a DTO from a Notification entity. + * + * @param notification the notification entity + * @return notification DTO + */ + public static NotificationDTO fromEntity(Notification notification) { + return NotificationDTO.builder() + .id(notification.getId()) + .type(notification.getType().name()) + .actorUri(notification.getActorUri()) + .actorDisplayName(notification.getActorDisplayName()) + .actorUsername(notification.getActorUsername()) + .actorAvatarUrl(notification.getActorAvatarUrl()) + .activityId(notification.getActivityId()) + .activityTitle(notification.getActivityTitle()) + .commentId(notification.getCommentId()) + .commentText(notification.getCommentText()) + .read(notification.isRead()) + .createdAt(notification.getCreatedAt()) + .readAt(notification.getReadAt()) + .build(); + } +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/Notification.java b/src/main/java/org/operaton/fitpub/model/entity/Notification.java new file mode 100644 index 0000000..9dfd122 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/Notification.java @@ -0,0 +1,158 @@ +package org.operaton.fitpub.model.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entity representing a user notification. + * Notifications are created for social interactions like likes, comments, follows, etc. + */ +@Entity +@Table(name = "notifications", indexes = { + @Index(name = "idx_notifications_user_id", columnList = "user_id"), + @Index(name = "idx_notifications_read_status", columnList = "user_id, is_read"), + @Index(name = "idx_notifications_created_at", columnList = "created_at") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private UUID id; + + /** + * The user who receives this notification. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + /** + * Type of notification. + */ + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 50) + private NotificationType type; + + /** + * The user who triggered this notification (actor). + * Can be null for system notifications. + */ + @Column(name = "actor_uri") + private String actorUri; + + /** + * Display name of the actor (cached for performance). + */ + @Column(name = "actor_display_name") + private String actorDisplayName; + + /** + * Username of the actor (cached for performance). + */ + @Column(name = "actor_username") + private String actorUsername; + + /** + * Avatar URL of the actor (cached for performance). + */ + @Column(name = "actor_avatar_url") + private String actorAvatarUrl; + + /** + * Related activity ID (for likes, comments on activities). + */ + @Column(name = "activity_id") + private UUID activityId; + + /** + * Activity title (cached for performance). + */ + @Column(name = "activity_title") + private String activityTitle; + + /** + * Related comment ID (if notification is about a comment). + */ + @Column(name = "comment_id") + private UUID commentId; + + /** + * Comment text preview (cached for performance). + */ + @Column(name = "comment_text", length = 200) + private String commentText; + + /** + * Whether the notification has been read. + */ + @Column(name = "is_read", nullable = false) + @Builder.Default + private boolean read = false; + + /** + * Timestamp when notification was created. + */ + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * Timestamp when notification was read. + */ + @Column(name = "read_at") + private LocalDateTime readAt; + + /** + * Types of notifications that can be sent. + */ + public enum NotificationType { + /** + * Someone liked your activity. + */ + ACTIVITY_LIKED, + + /** + * Someone commented on your activity. + */ + ACTIVITY_COMMENTED, + + /** + * Someone followed you. + */ + USER_FOLLOWED, + + /** + * Someone accepted your follow request. + */ + FOLLOW_ACCEPTED, + + /** + * Someone shared/announced your activity. + */ + ACTIVITY_SHARED, + + /** + * Someone mentioned you in a comment. + */ + MENTIONED_IN_COMMENT + } + + /** + * Mark this notification as read. + */ + public void markAsRead() { + this.read = true; + this.readAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/org/operaton/fitpub/repository/NotificationRepository.java b/src/main/java/org/operaton/fitpub/repository/NotificationRepository.java new file mode 100644 index 0000000..a9c358f --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/NotificationRepository.java @@ -0,0 +1,69 @@ +package org.operaton.fitpub.repository; + +import org.operaton.fitpub.model.entity.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 org.springframework.stereotype.Repository; + +import java.util.UUID; + +/** + * Repository for Notification entity. + */ +@Repository +public interface NotificationRepository extends JpaRepository { + + /** + * Find all notifications for a user, ordered by creation date (newest first). + * + * @param userId the user ID + * @param pageable pagination parameters + * @return page of notifications + */ + @Query("SELECT n FROM Notification n WHERE n.user.id = :userId ORDER BY n.createdAt DESC") + Page findByUserId(@Param("userId") UUID userId, Pageable pageable); + + /** + * Find unread notifications for a user, ordered by creation date (newest first). + * + * @param userId the user ID + * @param pageable pagination parameters + * @return page of unread notifications + */ + @Query("SELECT n FROM Notification n WHERE n.user.id = :userId AND n.read = false ORDER BY n.createdAt DESC") + Page findUnreadByUserId(@Param("userId") UUID userId, Pageable pageable); + + /** + * Count unread notifications for a user. + * + * @param userId the user ID + * @return count of unread notifications + */ + @Query("SELECT COUNT(n) FROM Notification n WHERE n.user.id = :userId AND n.read = false") + long countUnreadByUserId(@Param("userId") UUID userId); + + /** + * Mark all notifications as read for a user. + * + * @param userId the user ID + * @return number of notifications marked as read + */ + @Modifying + @Query("UPDATE Notification n SET n.read = true, n.readAt = CURRENT_TIMESTAMP WHERE n.user.id = :userId AND n.read = false") + int markAllAsReadByUserId(@Param("userId") UUID userId); + + /** + * Delete old read notifications for a user (older than a specified date). + * + * @param userId the user ID + * @param cutoffDate the cutoff date + * @return number of notifications deleted + */ + @Modifying + @Query("DELETE FROM Notification n WHERE n.user.id = :userId AND n.read = true AND n.createdAt < :cutoffDate") + int deleteOldReadNotifications(@Param("userId") UUID userId, @Param("cutoffDate") java.time.LocalDateTime cutoffDate); +} diff --git a/src/main/java/org/operaton/fitpub/service/NotificationService.java b/src/main/java/org/operaton/fitpub/service/NotificationService.java new file mode 100644 index 0000000..ca36f45 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/NotificationService.java @@ -0,0 +1,282 @@ +package org.operaton.fitpub.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.entity.*; +import org.operaton.fitpub.repository.NotificationRepository; +import org.operaton.fitpub.repository.RemoteActorRepository; +import org.operaton.fitpub.repository.UserRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Service for managing user notifications. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + private final RemoteActorRepository remoteActorRepository; + + @Value("${fitpub.base-url}") + private String baseUrl; + + /** + * Create a notification when someone likes an activity. + * + * @param activity the activity that was liked + * @param likerActorUri the URI of the user who liked the activity + */ + @Transactional + public void createActivityLikedNotification(Activity activity, String likerActorUri) { + // Don't notify if user liked their own activity + String activityOwnerUri = activity.getUser().getActorUri(baseUrl); + if (activityOwnerUri.equals(likerActorUri)) { + return; + } + + // Get actor information + ActorInfo actorInfo = getActorInfo(likerActorUri); + if (actorInfo == null) { + log.warn("Could not find actor info for URI: {}", likerActorUri); + return; + } + + Notification notification = Notification.builder() + .user(activity.getUser()) + .type(Notification.NotificationType.ACTIVITY_LIKED) + .actorUri(likerActorUri) + .actorDisplayName(actorInfo.displayName) + .actorUsername(actorInfo.username) + .actorAvatarUrl(actorInfo.avatarUrl) + .activityId(activity.getId()) + .activityTitle(activity.getTitle() != null ? activity.getTitle() : "Untitled Activity") + .build(); + + notificationRepository.save(notification); + log.debug("Created ACTIVITY_LIKED notification for user {} from {}", activity.getUser().getUsername(), actorInfo.username); + } + + /** + * Create a notification when someone comments on an activity. + * + * @param activity the activity that was commented on + * @param comment the comment + * @param commenterActorUri the URI of the user who commented + */ + @Transactional + public void createActivityCommentedNotification(Activity activity, Comment comment, String commenterActorUri) { + // Don't notify if user commented on their own activity + String activityOwnerUri = activity.getUser().getActorUri(baseUrl); + if (activityOwnerUri.equals(commenterActorUri)) { + return; + } + + // Get actor information + ActorInfo actorInfo = getActorInfo(commenterActorUri); + if (actorInfo == null) { + log.warn("Could not find actor info for URI: {}", commenterActorUri); + return; + } + + // Truncate comment text for preview + String commentPreview = comment.getContent(); + if (commentPreview != null && commentPreview.length() > 200) { + commentPreview = commentPreview.substring(0, 197) + "..."; + } + + Notification notification = Notification.builder() + .user(activity.getUser()) + .type(Notification.NotificationType.ACTIVITY_COMMENTED) + .actorUri(commenterActorUri) + .actorDisplayName(actorInfo.displayName) + .actorUsername(actorInfo.username) + .actorAvatarUrl(actorInfo.avatarUrl) + .activityId(activity.getId()) + .activityTitle(activity.getTitle() != null ? activity.getTitle() : "Untitled Activity") + .commentId(comment.getId()) + .commentText(commentPreview) + .build(); + + notificationRepository.save(notification); + log.debug("Created ACTIVITY_COMMENTED notification for user {} from {}", activity.getUser().getUsername(), actorInfo.username); + } + + /** + * Create a notification when someone follows a user. + * + * @param followedUser the user who was followed + * @param followerActorUri the URI of the user who followed + */ + @Transactional + public void createUserFollowedNotification(User followedUser, String followerActorUri) { + // Get actor information + ActorInfo actorInfo = getActorInfo(followerActorUri); + if (actorInfo == null) { + log.warn("Could not find actor info for URI: {}", followerActorUri); + return; + } + + Notification notification = Notification.builder() + .user(followedUser) + .type(Notification.NotificationType.USER_FOLLOWED) + .actorUri(followerActorUri) + .actorDisplayName(actorInfo.displayName) + .actorUsername(actorInfo.username) + .actorAvatarUrl(actorInfo.avatarUrl) + .build(); + + notificationRepository.save(notification); + log.debug("Created USER_FOLLOWED notification for user {} from {}", followedUser.getUsername(), actorInfo.username); + } + + /** + * Get all notifications for a user. + * + * @param userId the user ID + * @param pageable pagination parameters + * @return page of notifications + */ + @Transactional(readOnly = true) + public Page getNotifications(UUID userId, Pageable pageable) { + return notificationRepository.findByUserId(userId, pageable); + } + + /** + * Get unread notifications for a user. + * + * @param userId the user ID + * @param pageable pagination parameters + * @return page of unread notifications + */ + @Transactional(readOnly = true) + public Page getUnreadNotifications(UUID userId, Pageable pageable) { + return notificationRepository.findUnreadByUserId(userId, pageable); + } + + /** + * Count unread notifications for a user. + * + * @param userId the user ID + * @return count of unread notifications + */ + @Transactional(readOnly = true) + public long countUnreadNotifications(UUID userId) { + return notificationRepository.countUnreadByUserId(userId); + } + + /** + * Mark a notification as read. + * + * @param notificationId the notification ID + * @param userId the user ID (for authorization) + * @return true if marked as read, false if not found or not owned by user + */ + @Transactional + public boolean markAsRead(UUID notificationId, UUID userId) { + return notificationRepository.findById(notificationId) + .filter(n -> n.getUser().getId().equals(userId)) + .map(notification -> { + if (!notification.isRead()) { + notification.markAsRead(); + notificationRepository.save(notification); + } + return true; + }) + .orElse(false); + } + + /** + * Mark all notifications as read for a user. + * + * @param userId the user ID + * @return number of notifications marked as read + */ + @Transactional + public int markAllAsRead(UUID userId) { + return notificationRepository.markAllAsReadByUserId(userId); + } + + /** + * Delete a notification. + * + * @param notificationId the notification ID + * @param userId the user ID (for authorization) + * @return true if deleted, false if not found or not owned by user + */ + @Transactional + public boolean deleteNotification(UUID notificationId, UUID userId) { + return notificationRepository.findById(notificationId) + .filter(n -> n.getUser().getId().equals(userId)) + .map(notification -> { + notificationRepository.delete(notification); + return true; + }) + .orElse(false); + } + + /** + * Clean up old read notifications (older than 30 days). + * + * @param userId the user ID + * @return number of notifications deleted + */ + @Transactional + public int cleanupOldNotifications(UUID userId) { + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(30); + return notificationRepository.deleteOldReadNotifications(userId, cutoffDate); + } + + /** + * Helper method to get actor information from either local users or remote actors. + * + * @param actorUri the actor URI + * @return actor information or null if not found + */ + private ActorInfo getActorInfo(String actorUri) { + // Check if it's a local user + if (actorUri.startsWith(baseUrl)) { + String username = actorUri.substring(actorUri.lastIndexOf("/") + 1); + return userRepository.findByUsername(username) + .map(user -> new ActorInfo( + user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(), + user.getUsername(), + user.getAvatarUrl() + )) + .orElse(null); + } + + // Check if it's a remote actor + return remoteActorRepository.findByActorUri(actorUri) + .map(actor -> new ActorInfo( + actor.getDisplayName() != null ? actor.getDisplayName() : actor.getUsername(), + actor.getUsername(), + actor.getAvatarUrl() + )) + .orElse(null); + } + + /** + * Internal class to hold actor information. + */ + private static class ActorInfo { + String displayName; + String username; + String avatarUrl; + + ActorInfo(String displayName, String username, String avatarUrl) { + this.displayName = displayName; + this.username = username; + this.avatarUrl = avatarUrl; + } + } +} diff --git a/src/main/resources/db/migration/V9__create_notifications_table.sql b/src/main/resources/db/migration/V9__create_notifications_table.sql new file mode 100644 index 0000000..eb9da4d --- /dev/null +++ b/src/main/resources/db/migration/V9__create_notifications_table.sql @@ -0,0 +1,39 @@ +-- Create notifications table +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + type VARCHAR(50) NOT NULL, + actor_uri VARCHAR(500), + actor_display_name VARCHAR(255), + actor_username VARCHAR(255), + actor_avatar_url TEXT, + activity_id UUID, + activity_title VARCHAR(255), + comment_id UUID, + comment_text VARCHAR(200), + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + read_at TIMESTAMP, + CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_notifications_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + CONSTRAINT fk_notifications_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE +); + +-- Create indexes for efficient queries +CREATE INDEX idx_notifications_user_id ON notifications(user_id); +CREATE INDEX idx_notifications_read_status ON notifications(user_id, is_read); +CREATE INDEX idx_notifications_created_at ON notifications(created_at); + +-- Comments +COMMENT ON TABLE notifications IS 'User notifications for social interactions'; +COMMENT ON COLUMN notifications.type IS 'Type of notification: ACTIVITY_LIKED, ACTIVITY_COMMENTED, USER_FOLLOWED, FOLLOW_ACCEPTED, ACTIVITY_SHARED, MENTIONED_IN_COMMENT'; +COMMENT ON COLUMN notifications.actor_uri IS 'URI of the user who triggered the notification'; +COMMENT ON COLUMN notifications.actor_display_name IS 'Cached display name of the actor'; +COMMENT ON COLUMN notifications.actor_username IS 'Cached username of the actor'; +COMMENT ON COLUMN notifications.actor_avatar_url IS 'Cached avatar URL of the actor'; +COMMENT ON COLUMN notifications.activity_id IS 'Related activity ID (for likes, comments)'; +COMMENT ON COLUMN notifications.activity_title IS 'Cached activity title'; +COMMENT ON COLUMN notifications.comment_id IS 'Related comment ID'; +COMMENT ON COLUMN notifications.comment_text IS 'Preview of comment text'; +COMMENT ON COLUMN notifications.is_read IS 'Whether the notification has been read'; +COMMENT ON COLUMN notifications.read_at IS 'Timestamp when notification was read';