f0e69f78da
* Add an Admin Certification Photo workflow where we can request the user to upload a secondary form of ID (government issued photo ID showing their face and date of birth). * An admin rejection option can request secondary photo ID. * It sends a distinct e-mail to the user apart from the regular rejection email * It flags their cert photo as "Secondary Needed" forever: even if the user removes their cert photo and starts from scratch, it will immediately request secondary ID when uploading a new primary photo. * Secondary photos are deleted from the server on both Approve and Reject by the admin account, for user privacy. * If approved, a Secondary Approved=true boolean is stored in the database. This boolean is set to False if the user deletes their cert photo in the future.
538 lines
16 KiB
Go
538 lines
16 KiB
Go
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"
|
|
NotificationInnerCircle NotificationType = "inner_circle"
|
|
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{}
|
|
mapPrefs = map[uint64]map[string]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)
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// 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 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
|
|
CommentID uint64
|
|
Photo *Photo
|
|
Thread *Thread
|
|
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)
|
|
|
|
// 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 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|