// Copyright 2016 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package activities import ( "context" "fmt" "net/url" "strconv" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "xorm.io/builder" "xorm.io/xorm/schemas" ) type ( // NotificationStatus is the status of the notification (read or unread) NotificationStatus uint8 // NotificationSource is the source of the notification (issue, PR, commit, etc) NotificationSource uint8 ) const ( // NotificationStatusUnread represents an unread notification NotificationStatusUnread NotificationStatus = iota + 1 // NotificationStatusRead represents a read notification NotificationStatusRead // NotificationStatusPinned represents a pinned notification NotificationStatusPinned ) const ( // NotificationSourceIssue is a notification of an issue NotificationSourceIssue NotificationSource = iota + 1 // NotificationSourcePullRequest is a notification of a pull request NotificationSourcePullRequest // NotificationSourceCommit is a notification of a commit NotificationSourceCommit // NotificationSourceRepository is a notification for a repository NotificationSourceRepository ) // Notification represents a notification type Notification struct { ID int64 `xorm:"pk autoincr"` UserID int64 `xorm:"NOT NULL"` RepoID int64 `xorm:"NOT NULL"` Status NotificationStatus `xorm:"SMALLINT NOT NULL"` Source NotificationSource `xorm:"SMALLINT NOT NULL"` IssueID int64 `xorm:"NOT NULL"` CommitID string CommentID int64 UpdatedBy int64 `xorm:"NOT NULL"` Issue *issues_model.Issue `xorm:"-"` Repository *repo_model.Repository `xorm:"-"` Comment *issues_model.Comment `xorm:"-"` User *user_model.User `xorm:"-"` CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"` } // TableIndices implements xorm's TableIndices interface func (n *Notification) TableIndices() []*schemas.Index { indices := make([]*schemas.Index, 0, 8) usuuIndex := schemas.NewIndex("u_s_uu", schemas.IndexType) usuuIndex.AddColumn("user_id", "status", "updated_unix") indices = append(indices, usuuIndex) // Add the individual indices that were previously defined in struct tags userIDIndex := schemas.NewIndex("idx_notification_user_id", schemas.IndexType) userIDIndex.AddColumn("user_id") indices = append(indices, userIDIndex) repoIDIndex := schemas.NewIndex("idx_notification_repo_id", schemas.IndexType) repoIDIndex.AddColumn("repo_id") indices = append(indices, repoIDIndex) statusIndex := schemas.NewIndex("idx_notification_status", schemas.IndexType) statusIndex.AddColumn("status") indices = append(indices, statusIndex) sourceIndex := schemas.NewIndex("idx_notification_source", schemas.IndexType) sourceIndex.AddColumn("source") indices = append(indices, sourceIndex) issueIDIndex := schemas.NewIndex("idx_notification_issue_id", schemas.IndexType) issueIDIndex.AddColumn("issue_id") indices = append(indices, issueIDIndex) commitIDIndex := schemas.NewIndex("idx_notification_commit_id", schemas.IndexType) commitIDIndex.AddColumn("commit_id") indices = append(indices, commitIDIndex) updatedByIndex := schemas.NewIndex("idx_notification_updated_by", schemas.IndexType) updatedByIndex.AddColumn("updated_by") indices = append(indices, updatedByIndex) return indices } func init() { db.RegisterModel(new(Notification)) } // CreateRepoTransferNotification creates notification for the user a repository was transferred to func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error { return db.WithTx(ctx, func(ctx context.Context) error { var notify []*Notification if newOwner.IsOrganization() { users, err := organization.GetUsersWhoCanCreateOrgRepo(ctx, newOwner.ID) if err != nil || len(users) == 0 { return err } for i := range users { notify = append(notify, &Notification{ UserID: i, RepoID: repo.ID, Status: NotificationStatusUnread, UpdatedBy: doer.ID, Source: NotificationSourceRepository, }) } } else { notify = []*Notification{{ UserID: newOwner.ID, RepoID: repo.ID, Status: NotificationStatusUnread, UpdatedBy: doer.ID, Source: NotificationSourceRepository, }} } return db.Insert(ctx, notify) }) } func createIssueNotification(ctx context.Context, userID int64, issue *issues_model.Issue, commentID, updatedByID int64) error { notification := &Notification{ UserID: userID, RepoID: issue.RepoID, Status: NotificationStatusUnread, IssueID: issue.ID, CommentID: commentID, UpdatedBy: updatedByID, } if issue.IsPull { notification.Source = NotificationSourcePullRequest } else { notification.Source = NotificationSourceIssue } return db.Insert(ctx, notification) } func updateIssueNotification(ctx context.Context, userID, issueID, commentID, updatedByID int64) error { notification, err := GetIssueNotification(ctx, userID, issueID) if err != nil { return err } // NOTICE: Only update comment id when the before notification on this issue is read, otherwise you may miss some old comments. // But we need update update_by so that the notification will be reorder var cols []string if notification.Status == NotificationStatusRead { notification.Status = NotificationStatusUnread notification.CommentID = commentID cols = []string{"status", "update_by", "comment_id"} } else { notification.UpdatedBy = updatedByID cols = []string{"update_by"} } _, err = db.GetEngine(ctx).ID(notification.ID).Cols(cols...).Update(notification) return err } // GetIssueNotification return the notification about an issue func GetIssueNotification(ctx context.Context, userID, issueID int64) (*Notification, error) { notification := new(Notification) _, err := db.GetEngine(ctx). Where("user_id = ?", userID). And("issue_id = ?", issueID). Get(notification) return notification, err } // LoadAttributes load Repo Issue User and Comment if not loaded func (n *Notification) LoadAttributes(ctx context.Context) (err error) { if err = n.loadRepo(ctx); err != nil { return err } if err = n.loadIssue(ctx); err != nil { return err } if err = n.loadUser(ctx); err != nil { return err } if err = n.loadComment(ctx); err != nil { return err } return err } func (n *Notification) loadRepo(ctx context.Context) (err error) { if n.Repository == nil { n.Repository, err = repo_model.GetRepositoryByID(ctx, n.RepoID) if err != nil { return fmt.Errorf("getRepositoryByID [%d]: %w", n.RepoID, err) } } return nil } func (n *Notification) loadIssue(ctx context.Context) (err error) { if n.Issue == nil && n.IssueID != 0 { n.Issue, err = issues_model.GetIssueByID(ctx, n.IssueID) if err != nil { return fmt.Errorf("getIssueByID [%d]: %w", n.IssueID, err) } return n.Issue.LoadAttributes(ctx) } return nil } func (n *Notification) loadComment(ctx context.Context) (err error) { if n.Comment == nil && n.CommentID != 0 { n.Comment, err = issues_model.GetCommentByID(ctx, n.CommentID) if err != nil { if issues_model.IsErrCommentNotExist(err) { return issues_model.ErrCommentNotExist{ ID: n.CommentID, IssueID: n.IssueID, } } return err } } return nil } func (n *Notification) loadUser(ctx context.Context) (err error) { if n.User == nil { n.User, err = user_model.GetUserByID(ctx, n.UserID) if err != nil { return fmt.Errorf("getUserByID [%d]: %w", n.UserID, err) } } return nil } // GetRepo returns the repo of the notification func (n *Notification) GetRepo(ctx context.Context) (*repo_model.Repository, error) { return n.Repository, n.loadRepo(ctx) } // GetIssue returns the issue of the notification func (n *Notification) GetIssue(ctx context.Context) (*issues_model.Issue, error) { return n.Issue, n.loadIssue(ctx) } // HTMLURL formats a URL-string to the notification func (n *Notification) HTMLURL(ctx context.Context) string { switch n.Source { case NotificationSourceIssue, NotificationSourcePullRequest: if n.Comment != nil { return n.Comment.HTMLURL(ctx) } return n.Issue.HTMLURL() case NotificationSourceCommit: return n.Repository.HTMLURL() + "/commit/" + url.PathEscape(n.CommitID) case NotificationSourceRepository: return n.Repository.HTMLURL() } return "" } // Link formats a relative URL-string to the notification func (n *Notification) Link(ctx context.Context) string { switch n.Source { case NotificationSourceIssue, NotificationSourcePullRequest: if n.Comment != nil { return n.Comment.Link(ctx) } return n.Issue.Link() case NotificationSourceCommit: return n.Repository.Link() + "/commit/" + url.PathEscape(n.CommitID) case NotificationSourceRepository: return n.Repository.Link() } return "" } // APIURL formats a URL-string to the notification func (n *Notification) APIURL() string { return setting.AppURL + "api/v1/notifications/threads/" + strconv.FormatInt(n.ID, 10) } func notificationExists(notifications []*Notification, issueID, userID int64) bool { for _, notification := range notifications { if notification.IssueID == issueID && notification.UserID == userID { return true } } return false } // UserIDCount is a simple coalition of UserID and Count type UserIDCount struct { UserID int64 Count int64 } // GetUIDsAndNotificationCounts returns the unread counts for every user between the two provided times. // It must return all user IDs which appear during the period, including count=0 for users who have read all. func GetUIDsAndNotificationCounts(ctx context.Context, since, until timeutil.TimeStamp) ([]UserIDCount, error) { sql := `SELECT user_id, sum(case when status= ? then 1 else 0 end) AS count FROM notification ` + `WHERE user_id IN (SELECT user_id FROM notification WHERE updated_unix >= ? AND ` + `updated_unix < ?) GROUP BY user_id` var res []UserIDCount return res, db.GetEngine(ctx).SQL(sql, NotificationStatusUnread, since, until).Find(&res) } // SetIssueReadBy sets issue to be read by given user. func SetIssueReadBy(ctx context.Context, issueID, userID int64) error { if err := issues_model.UpdateIssueUserByRead(ctx, userID, issueID); err != nil { return err } return setIssueNotificationStatusReadIfUnread(ctx, userID, issueID) } func setIssueNotificationStatusReadIfUnread(ctx context.Context, userID, issueID int64) error { notification, err := GetIssueNotification(ctx, userID, issueID) // ignore if not exists if err != nil { return nil } if notification.Status != NotificationStatusUnread { return nil } notification.Status = NotificationStatusRead _, err = db.GetEngine(ctx).ID(notification.ID).Cols("status").Update(notification) return err } // SetRepoReadBy sets repo to be visited by given user. func SetRepoReadBy(ctx context.Context, userID, repoID int64) error { _, err := db.GetEngine(ctx).Where(builder.Eq{ "user_id": userID, "status": NotificationStatusUnread, "source": NotificationSourceRepository, "repo_id": repoID, }).Cols("status").Update(&Notification{Status: NotificationStatusRead}) return err } // SetNotificationStatus change the notification status func SetNotificationStatus(ctx context.Context, notificationID int64, user *user_model.User, status NotificationStatus) (*Notification, error) { notification, err := GetNotificationByID(ctx, notificationID) if err != nil { return notification, err } if notification.UserID != user.ID { return nil, fmt.Errorf("Can't change notification of another user: %d, %d", notification.UserID, user.ID) } notification.Status = status _, err = db.GetEngine(ctx).ID(notificationID).Update(notification) return notification, err } // GetNotificationByID return notification by ID func GetNotificationByID(ctx context.Context, notificationID int64) (*Notification, error) { notification := new(Notification) ok, err := db.GetEngine(ctx). Where("id = ?", notificationID). Get(notification) if err != nil { return nil, err } if !ok { return nil, db.ErrNotExist{Resource: "notification", ID: notificationID} } return notification, nil } // UpdateNotificationStatuses updates the statuses of all of a user's notifications that are of the currentStatus type to the desiredStatus func UpdateNotificationStatuses(ctx context.Context, user *user_model.User, currentStatus, desiredStatus NotificationStatus) error { n := &Notification{Status: desiredStatus, UpdatedBy: user.ID} _, err := db.GetEngine(ctx). Where("user_id = ? AND status = ?", user.ID, currentStatus). Cols("status", "updated_by", "updated_unix"). Update(n) return err }