package models import ( "errors" "fmt" "strings" "time" "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/redis" "gorm.io/gorm" ) // Thread table - a post within a Forum. type Thread struct { ID uint64 `gorm:"primaryKey"` ForumID uint64 `gorm:"index"` Forum Forum Pinned bool `gorm:"index"` Explicit bool `gorm:"index"` NoReply bool Title string CommentID uint64 `gorm:"index"` Comment Comment // first comment of the thread PollID *uint64 `gorm:"poll_id"` Poll Poll // if the thread has a poll attachment Views uint64 CreatedAt time.Time UpdatedAt time.Time } // Preload related tables for the forum (classmethod). func (f *Thread) Preload() *gorm.DB { return DB.Preload("Forum").Preload("Comment.User.ProfilePhoto").Preload("Poll") } // GetThread by ID. func GetThread(id uint64) (*Thread, error) { t := &Thread{} result := t.Preload().First(&t, id) return t, result.Error } // GetThreads queries a set of thread IDs and returns them mapped. func GetThreads(IDs []uint64) (map[uint64]*Thread, error) { var ( mt = map[uint64]*Thread{} ts = []*Thread{} ) result := (&Thread{}).Preload().Where("id IN ?", IDs).Find(&ts) for _, row := range ts { mt[row.ID] = row } return mt, result.Error } // CreateThread creates a new thread with proper Comment structure. func CreateThread(user *User, forumID uint64, title, message string, pinned, explicit, noReply bool) (*Thread, error) { thread := &Thread{ ForumID: forumID, Title: title, Pinned: pinned, Explicit: explicit, NoReply: noReply && user.IsAdmin, Comment: Comment{ User: *user, Message: message, }, } log.Error("CreateThread: Going to post %+v", thread) // Create the thread & comment first... result := DB.Create(thread) if result.Error != nil { return nil, result.Error } // Fill out the Comment with proper reverse foreign keys. thread.Comment.TableName = "threads" thread.Comment.TableID = thread.ID log.Error("Saving updated comment: %+v", thread) result = DB.Save(&thread.Comment) return thread, result.Error } // Pages returns the number of pages in the thread - also useful to find out // what is the final page number that has any posts. func (t *Thread) Pages() int { // How many posts total? var postCount int64 var query = DB.Table( "comments", ).Select( "count(id) AS count", ).Where( "table_name = 'threads' AND table_id = ?", t.ID, ).Count(&postCount) if query.Error != nil { log.Error("SQL error getting post count for thread %d: %s", t.ID, query.Error) } // Return what the Paginator would say is the inclusive page count. return Pagination{ PerPage: config.PageSizeThreadList, Total: postCount, }.Pages() } // Reply to a thread, adding an additional comment. func (t *Thread) Reply(user *User, message string) (*Comment, error) { // Save the thread on reply, updating its timestamp. if err := t.Save(); err != nil { log.Error("Thread.Reply: couldn't ping UpdatedAt on thread: %s", err) } return AddComment(user, "threads", t.ID, message) } // DeleteReply removes a comment from a thread. If it is the primary comment, deletes the whole thread. func (t *Thread) DeleteReply(comment *Comment) error { // Sanity check that this reply is one of ours. if !(comment.TableName == "threads" && comment.TableID == t.ID) { return errors.New("that comment doesn't belong to this thread") } // Is this the primary comment that started the thread? If so, delete the whole thread. if comment.ID == t.CommentID { log.Error("DeleteReply(%d): this is the parent comment of a thread (%d '%s'), remove the whole thread", comment.ID, t.ID, t.Title) return t.Delete() } // Remove just this comment. return comment.Delete() } // PinnedThreads returns all pinned threads in a forum (there should generally be few of these). func PinnedThreads(forum *Forum) ([]*Thread, error) { var ( ts = []*Thread{} query = (&Thread{}).Preload().Where( "forum_id = ? AND pinned IS TRUE", forum.ID, ).Order("updated_at desc") ) result := query.Find(&ts) return ts, result.Error } // PaginateThreads provides a forum index view of posts, minus pinned posts. func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, error) { var ( ts = []*Thread{} query = (&Thread{}).Preload() wheres = []string{} placeholders = []interface{}{} ) // Always filters. wheres = append(wheres, "forum_id = ? AND pinned IS NOT TRUE") placeholders = append(placeholders, forum.ID) // If the user hasn't opted in for Explicit, hide NSFW threads. if !user.Explicit && !user.IsAdmin { wheres = append(wheres, "explicit IS NOT TRUE") } query = query.Where( strings.Join(wheres, " AND "), placeholders..., ).Order(pager.Sort) query.Model(&Thread{}).Count(&pager.Total) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ts) // Inject user relationships into these threads' comments' users. SetUserRelationshipsInThreads(user, ts) return ts, result.Error } // View a thread, incrementing its View count but not its UpdatedAt. // Debounced with a Redis key. func (t *Thread) View(userID uint64) error { // Debounce this. var redisKey = fmt.Sprintf(config.ThreadViewDebounceRedisKey, userID, t.ID) if redis.Exists(redisKey) { return nil } redis.Set(redisKey, nil, config.ThreadViewDebounceCooldown) return DB.Model(&Thread{}).Where( "id = ?", t.ID, ).Updates(map[string]interface{}{ "views": t.Views + 1, "updated_at": t.UpdatedAt, }).Error } // Save a thread, updating its timestamp. func (t *Thread) Save() error { return DB.Save(t).Error } // Delete a thread and all of its comments. func (t *Thread) Delete() error { // Unlink the parent comment from the thread to resolve a foreign key constraint in Postgres. if result := DB.Model(&Thread{}).Where("id = ?", t.ID).Update("comment_id", nil); result.Error != nil { return fmt.Errorf("Thread.Delete: couldn't unlink parent comment: %s", result.Error) } // Remove all comments. result := DB.Where( "table_name = ? AND table_id = ?", "threads", t.ID, ).Delete(&Comment{}) if result.Error != nil { return fmt.Errorf("deleting comments for thread: %s", result.Error) } // Remove the thread itself. return DB.Delete(t).Error } // ThreadStatistics queries for reply/view count for threads. type ThreadStatistics struct { Replies uint64 Views uint64 } type ThreadStatsMap map[uint64]*ThreadStatistics // MapThreadStatistics looks up statistics for a set of threads. func MapThreadStatistics(threads []*Thread) ThreadStatsMap { var ( result = ThreadStatsMap{} IDs = []uint64{} ) // Collect thread IDs and initialize the map. for _, thread := range threads { IDs = append(IDs, thread.ID) result[thread.ID] = &ThreadStatistics{ Views: thread.Views, } } // Hold the result of the count/group by query. type group struct { ID uint64 Replies uint64 } var groups = []group{} // Count comments grouped by thread IDs. err := DB.Table( "comments", ).Select( "table_id AS id, count(id) AS replies", ).Where( "table_name = ? AND table_id IN ?", "threads", IDs, ).Group("table_id").Scan(&groups) if err != nil { log.Error("MapThreadStatistics: SQL error: %s", err) } // Map the results in. for _, row := range groups { log.Error("Got row: %+v", row) if stats, ok := result[row.ID]; ok { stats.Replies = row.Replies // Remove the OG comment from the count. if stats.Replies > 0 { stats.Replies-- } } } return result } // Has stats for this thread? (we should..) func (ts ThreadStatsMap) Has(threadID uint64) bool { _, ok := ts[threadID] return ok } // Get thread stats. func (ts ThreadStatsMap) Get(threadID uint64) *ThreadStatistics { if stats, ok := ts[threadID]; ok { return stats } return nil }