Notification System

This commit is contained in:
Tim Zöller 2025-12-04 08:31:56 +01:00
parent 3ec22c4770
commit 2bc865fefd
8 changed files with 777 additions and 0 deletions

View file

@ -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) {

View file

@ -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;

View file

@ -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<Page<NotificationDTO>> getNotifications(
@AuthenticationPrincipal UserDetails userDetails,
Pageable pageable
) {
User user = getUser(userDetails);
Page<Notification> notifications = notificationService.getNotifications(user.getId(), pageable);
Page<NotificationDTO> 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<Page<NotificationDTO>> getUnreadNotifications(
@AuthenticationPrincipal UserDetails userDetails,
Pageable pageable
) {
User user = getUser(userDetails);
Page<Notification> notifications = notificationService.getUnreadNotifications(user.getId(), pageable);
Page<NotificationDTO> 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<Map<String, Long>> getUnreadCount(
@AuthenticationPrincipal UserDetails userDetails
) {
User user = getUser(userDetails);
long count = notificationService.countUnreadNotifications(user.getId());
Map<String, Long> 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<Void> 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<Map<String, Integer>> markAllAsRead(
@AuthenticationPrincipal UserDetails userDetails
) {
User user = getUser(userDetails);
int count = notificationService.markAllAsRead(user.getId());
Map<String, Integer> 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<Void> 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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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<Notification, UUID> {
/**
* 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<Notification> 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<Notification> 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);
}

View file

@ -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<Notification> 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<Notification> 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;
}
}
}

View file

@ -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';