package models import ( "errors" "fmt" "strings" "time" "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "gorm.io/gorm" ) // Notification table. type Notification struct { ID uint64 `gorm:"primaryKey"` UserID uint64 `gorm:"index"` // who it belongs to AboutUserID *uint64 `form:"index"` // the other party of this notification AboutUser User `gorm:"foreignKey:about_user_id"` Type NotificationType // like, comment, ... Read bool `gorm:"index"` TableName string // on which of your tables (photos, comments, ...) TableID uint64 Message string // text associated, e.g. copy of comment added Link string // associated URL, e.g. for comments CreatedAt time.Time UpdatedAt time.Time } // Preload related tables for the forum (classmethod). func (n *Notification) Preload() *gorm.DB { return DB.Preload("AboutUser.ProfilePhoto") } type NotificationType string const ( NotificationLike NotificationType = "like" NotificationFriendApproved NotificationType = "friendship_approved" NotificationComment NotificationType = "comment" NotificationAlsoCommented NotificationType = "also_comment" NotificationAlsoPosted NotificationType = "also_posted" // forum replies NotificationCertRejected NotificationType = "cert_rejected" NotificationCertSecondary NotificationType = "cert_secondary" // secondary cert photo requested NotificationCertApproved NotificationType = "cert_approved" NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants NotificationNewPhoto NotificationType = "new_photo" NotificationForumModerator NotificationType = "forum_moderator" // appointed as a forum moderator NotificationExplicitPhoto NotificationType = "explicit_photo" // user photo was flagged explicit NotificationCustom NotificationType = "custom" // custom message pushed ) // CreateNotification inserts a new notification into the database. func CreateNotification(n *Notification) error { // Insert via raw SQL query, reasoning: // the AboutUser relationship has gorm do way too much work: // - Upsert the user profile photo // - Upsert the user profile fields // - Upsert the user row itself // .. and if we notify all your friends, all these wasteful queries ran // for every single notification created! if n.AboutUserID == nil && n.AboutUser.ID > 0 { n.AboutUserID = &n.AboutUser.ID } return DB.Exec( ` INSERT INTO notifications (user_id, about_user_id, type, read, table_name, table_id, message, link, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, n.UserID, n.AboutUserID, n.Type, false, n.TableName, n.TableID, n.Message, n.Link, time.Now(), time.Now(), ).Error } // GetNotification by ID. func GetNotification(id uint64) (*Notification, error) { var n *Notification result := DB.Model(n).First(&n, id) return n, result.Error } // NotificationOptOut checks whether the user opts-out of a class of notification. func (u *User) NotificationOptOut(name string) bool { return u.GetProfileField(name) == "true" } // RemoveNotification about a table ID, e.g. when removing a like. func RemoveNotification(tableName string, tableID uint64) error { result := DB.Where( "table_name = ? AND table_id = ?", tableName, tableID, ).Delete(&Notification{}) return result.Error } // RemoveAlsoPostedNotification removes a 'has also posted' notification if the comment is later deleted. // // This is specialized for deleting replies to forum threads where subscribers were notified that the // user has AlsoPosted on that thread. If the user deletes their comment, this specific notification // needs to be revoked from people who received it before, so the head of their original comment is not // leaked on their notifications page. // // These notifications have a Type=also_posted TableName=threads TableID=threads.ID with the only hard // link to the specific comment on that thread being the hyperlink URL that goes to their comment. func RemoveAlsoPostedNotification(thread *Thread, commentID uint64) error { // Match the specific notification by its link URL. var ( // Modern link URL ('/go/comment?id=1234' which finds the right page to see the comment) newLink = fmt.Sprintf("/go/comment?id=%d", commentID) // Legacy link URL ('/forum/thread/123?page=4#p456') which embeds the thread ID, an // optional query string (page number) and the comment ID anchor. legacyLink = fmt.Sprintf("/forum/thread/%d%%#p%d", thread.ID, commentID) ) result := DB.Where( "type = ? AND table_name = 'threads' AND table_id = ? AND (link = ? OR link LIKE ?)", NotificationAlsoPosted, thread.ID, newLink, legacyLink, ).Delete(&Notification{}) return result.Error } // RemoveNotificationBulk about several table IDs, e.g. when bulk removing private photo upload // notifications for everybody on the site. func RemoveNotificationBulk(tableName string, tableIDs []uint64) error { result := DB.Where( "table_name = ? AND table_id IN ?", tableName, tableIDs, ).Delete(&Notification{}) return result.Error } // RemoveSpecificNotification to remove more specialized notifications where just removing by // table name+ID is not adequate, e.g. for Private Photo Unlocks. func RemoveSpecificNotification(userID uint64, t NotificationType, tableName string, tableID uint64) error { result := DB.Where( "user_id = ? AND type = ? AND table_name = ? AND table_id = ?", userID, t, tableName, tableID, ).Delete(&Notification{}) return result.Error } // RemoveSpecificNotificationAboutUser to remove a specific table_name/id notification about a user, // e.g. when removing a like on a photo. func RemoveSpecificNotificationAboutUser(userID, aboutUserID uint64, t NotificationType, tableName string, tableID uint64) error { result := DB.Where( "user_id = ? AND about_user_id = ? AND type = ? AND table_name = ? AND table_id = ?", userID, aboutUserID, t, tableName, tableID, ).Delete(&Notification{}) return result.Error } // RemoveSpecificNotificationBulk can remove notifications about several TableIDs of the same type, // e.g. to bulk remove new private photo upload notifications. func RemoveSpecificNotificationBulk(users []*User, t NotificationType, tableName string, tableIDs []uint64) error { var userIDs = []uint64{} for _, user := range users { userIDs = append(userIDs, user.ID) } if len(userIDs) == 0 { // Nothing to do. return errors.New("no user IDs given") } result := DB.Where( "user_id IN ? AND type = ? AND table_name = ? AND table_id IN ?", userIDs, t, tableName, tableIDs, ).Delete(&Notification{}) return result.Error } // MarkNotificationsRead sets all a user's notifications to read. func MarkNotificationsRead(user *User) error { return DB.Model(&Notification{}).Where( "user_id = ? AND read IS NOT TRUE", user.ID, ).Update("read", true).Error } // ClearAllNotifications removes a user's entire notification table. func ClearAllNotifications(user *User) error { return DB.Where( "user_id = ?", user.ID, ).Delete(&Notification{}).Error } // CountUnreadNotifications gets the count of unread Notifications for a user. func CountUnreadNotifications(user *User) (int64, error) { var ( blockedUserIDs = BlockedUserIDs(user) where = []string{ "user_id = ? AND read = ?", } placeholders = []interface{}{ user.ID, false, } ) // Blocking user IDs? if len(blockedUserIDs) > 0 { where = append(where, "about_user_id NOT IN ?") placeholders = append(placeholders, blockedUserIDs) } // Don't show messages from banned or disabled accounts. where = append(where, ` EXISTS ( SELECT 1 FROM users WHERE users.id = notifications.about_user_id AND users.status = 'active' ) `) query := DB.Where( strings.Join(where, " AND "), placeholders..., ) var count int64 result := query.Model(&Notification{}).Count(&count) return count, result.Error } // PaginateNotifications returns the user's notifications. func PaginateNotifications(user *User, filters NotificationFilter, pager *Pagination) ([]*Notification, error) { var ( ns = []*Notification{} blockedUserIDs = BlockedUserIDs(user) where = []string{ "user_id = ?", } placeholders = []interface{}{ user.ID, } ) // Suppress historic notifications about blocked users. if len(blockedUserIDs) > 0 { where = append(where, "about_user_id NOT IN ?") placeholders = append(placeholders, blockedUserIDs) } // Don't show notifications from banned or disabled accounts. where = append(where, ` EXISTS ( SELECT 1 FROM users WHERE users.id = notifications.about_user_id AND users.status = 'active' ) `) // Mix in notification type filters? if w, ph, ok := filters.Query(); ok { where = append(where, w) placeholders = append(placeholders, ph) } query := (&Notification{}).Preload().Where( strings.Join(where, " AND "), placeholders..., ).Order( pager.Sort, ) query.Model(&Notification{}).Count(&pager.Total) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ns) return ns, result.Error } // FilterPhotoUploadNotificationUserIDs will narrow a set of UserIDs who would be notified about // a new photo upload to respect each user's preference for notification opt-outs. // // It is assumed that userIDs are already narrowed down to Friends of the current user. func FilterPhotoUploadNotificationUserIDs(photo *Photo, userIDs []uint64) []uint64 { var ( result = []uint64{} // Collect notification opt-out profile fields and map them by user ID for easy lookup. prefs = []*ProfileField{} // Global Notification preferences mutes = []*Subscription{} // Individual "friend.photos" notification mutes mapPrefs = map[uint64]map[string]bool{} mapMutes = map[uint64]bool{} ) if len(userIDs) == 0 { return userIDs } // Collect opt-out preferences for these users. r := DB.Model(&ProfileField{}).Where( "user_id IN ? AND name IN ?", userIDs, []string{ config.NotificationOptOutFriendPhotos, // all friends' photos config.NotificationOptOutPrivatePhotos, // private photos from friends config.NotificationOptOutExplicitPhotos, // explicit photos }, ).Find(&prefs) if r.Error != nil { log.Error("FilterPhotoUploadNotificationUserIDs: couldn't collect user preferences: %s", r.Error) } // Collect any muted notification threads, e.g. the user doesn't want your new photo notifications. r = DB.Model(&Subscription{}).Where( "table_name = 'friend.photos' AND table_id = ? AND subscribed IS FALSE", photo.UserID, ).Find(&mutes) if r.Error != nil { log.Error("FilterPhotoUploadNotificationUserIDs: couldn't collect user notification mutes: %s", r.Error) } // Map the preferences by user ID. for _, row := range prefs { if _, ok := mapPrefs[row.UserID]; !ok { mapPrefs[row.UserID] = map[string]bool{} } mapPrefs[row.UserID][row.Name] = row.Value == "true" } for _, row := range mutes { mapMutes[row.UserID] = true } // Narrow the notification recipients based on photo property and their preferences. for _, userID := range userIDs { // Skip explicit photo notification? if photo.Explicit && mapPrefs[userID][config.NotificationOptOutExplicitPhotos] { continue } // Skip private photo notification? if photo.Visibility == PhotoPrivate && mapPrefs[userID][config.NotificationOptOutPrivatePhotos] { continue } // Skip friend photo notifications? if mapPrefs[userID][config.NotificationOptOutFriendPhotos] { continue } // They muted your friend "new photo" notifications? if mapMutes[userID] { continue } // They get the notification. result = append(result, userID) } return result } // Save a notification. func (n *Notification) Save() error { return DB.Save(n).Error } // Delete a notification. func (n *Notification) Delete() error { return DB.Delete(n).Error } // NotificationBody can store remote tables mapped. type NotificationBody struct { PhotoID uint64 ThreadID uint64 ForumID uint64 CommentID uint64 Photo *Photo Thread *Thread Forum *Forum Comment *Comment } type NotificationMap map[uint64]*NotificationBody // Get a notification's body from the map. func (m NotificationMap) Get(id uint64) *NotificationBody { if body, ok := m[id]; ok { return body } return &NotificationBody{} } // MapNotifications loads associated assets, like Photos, mapped to their notification ID. func MapNotifications(ns []*Notification) NotificationMap { var ( IDs = []uint64{} result = NotificationMap{} ) // Collect notification IDs. for _, row := range ns { IDs = append(IDs, row.ID) result[row.ID] = &NotificationBody{} } result.mapNotificationPhotos(IDs) result.mapNotificationThreads(IDs) result.mapNotificationForums(IDs) // NOTE: comment loading is not used - was added when trying to add "Like" buttons inside // your Comment notifications. But when a photo is commented on, the notification table_name=photos, // with the comment ID not so readily accessible. // // result.mapNotificationComments(IDs) return result } // Helper function of MapNotifications to eager load Photo attachments. func (nm NotificationMap) mapNotificationPhotos(IDs []uint64) { type scanner struct { PhotoID uint64 NotificationID uint64 } var scan []scanner // Load all of these that have photos. err := DB.Table( "notifications", ).Joins( "JOIN photos ON (notifications.table_name='photos' AND notifications.table_id=photos.id)", ).Select( "photos.id AS photo_id", "notifications.id AS notification_id", ).Where( "notifications.id IN ?", IDs, ).Scan(&scan) if err.Error != nil { log.Error("Couldn't select photo IDs for notifications: %s", err.Error) } // Collect and load all the photos by ID. var photoIDs = []uint64{} for _, row := range scan { // Store the photo ID in the result now. nm[row.NotificationID].PhotoID = row.PhotoID photoIDs = append(photoIDs, row.PhotoID) } // Load the photos. if len(photoIDs) > 0 { if photos, err := GetPhotos(photoIDs); err != nil { log.Error("Couldn't load photo IDs for notifications: %s", err) } else { // Marry them to their notification IDs. for _, body := range nm { if photo, ok := photos[body.PhotoID]; ok { body.Photo = photo } } } } } // Helper function of MapNotifications to eager load Thread attachments. func (nm NotificationMap) mapNotificationThreads(IDs []uint64) { type scanner struct { ThreadID uint64 NotificationID uint64 } var scan []scanner // Load all of these that have threads. err := DB.Table( "notifications", ).Joins( "JOIN threads ON (notifications.table_name='threads' AND notifications.table_id=threads.id)", ).Select( "threads.id AS thread_id", "notifications.id AS notification_id", ).Where( "notifications.id IN ?", IDs, ).Scan(&scan) if err.Error != nil { log.Error("Couldn't select thread IDs for notifications: %s", err.Error) } // Collect and load all the threads by ID. var threadIDs = []uint64{} for _, row := range scan { // Store the thread ID in the result now. nm[row.NotificationID].ThreadID = row.ThreadID threadIDs = append(threadIDs, row.ThreadID) } // Load the threads. if len(threadIDs) > 0 { if threads, err := GetThreads(threadIDs); err != nil { log.Error("Couldn't load thread IDs for notifications: %s", err) } else { // Marry them to their notification IDs. for _, body := range nm { if thread, ok := threads[body.ThreadID]; ok { body.Thread = thread } } } } } // Helper function of MapNotifications to eager load Forum attachments. func (nm NotificationMap) mapNotificationForums(IDs []uint64) { type scanner struct { ForumID uint64 NotificationID uint64 } var scan []scanner // Load all of these that have forums. err := DB.Table( "notifications", ).Joins( "JOIN forums ON (notifications.table_name='forums' AND notifications.table_id=forums.id)", ).Select( "forums.id AS forum_id", "notifications.id AS notification_id", ).Where( "notifications.id IN ?", IDs, ).Scan(&scan) if err.Error != nil { log.Error("Couldn't select forum IDs for notifications: %s", err.Error) } // Collect and load all the forums by ID. var forumIDs = []uint64{} for _, row := range scan { // Store the forum ID in the result now. nm[row.NotificationID].ForumID = row.ForumID forumIDs = append(forumIDs, row.ForumID) } // Load the forums. if len(forumIDs) > 0 { if forums, err := GetForums(forumIDs); err != nil { log.Error("Couldn't load forum IDs for notifications: %s", err) } else { // Marry them to their notification IDs. for _, body := range nm { if forum, ok := forums[body.ForumID]; ok { body.Forum = forum } } } } } // Helper function of MapNotifications to eager load Comment attachments. func (nm NotificationMap) mapNotificationComments(IDs []uint64) { type scanner struct { CommentID uint64 NotificationID uint64 } var scan []scanner // Load all of these that have comments. err := DB.Table( "notifications", ).Joins( "JOIN comments ON (notifications.table_name='comments' AND notifications.table_id=comments.id)", ).Select( "comments.id AS comment_id", "notifications.id AS notification_id", ).Where( "notifications.id IN ?", IDs, ).Scan(&scan) if err.Error != nil { log.Error("Couldn't select comment IDs for notifications: %s", err.Error) } // Collect and load all the comments by ID. var commentIDs = []uint64{} for _, row := range scan { // Store the comment ID in the result now. nm[row.NotificationID].CommentID = row.CommentID commentIDs = append(commentIDs, row.CommentID) } // Load the comments. if len(commentIDs) > 0 { if comments, err := GetComments(commentIDs); err != nil { log.Error("Couldn't load comment IDs for notifications: %s", err) } else { // Marry them to their notification IDs. for _, body := range nm { if comment, ok := comments[body.CommentID]; ok { body.Comment = comment } } } } }