diff --git a/integrations/api_notification_test.go b/integrations/api_notification_test.go new file mode 100644 index 000000000..2c5477dfb --- /dev/null +++ b/integrations/api_notification_test.go @@ -0,0 +1,106 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestAPINotification(t *testing.T) { + defer prepareTestEnv(t)() + + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) + thread5 := models.AssertExistsAndLoadBean(t, &models.Notification{ID: 5}).(*models.Notification) + assert.NoError(t, thread5.LoadAttributes()) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session) + + // -- GET /notifications -- + // test filter + since := "2000-01-01T00%3A50%3A01%2B00%3A00" //946687801 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?since=%s&token=%s", since, token)) + resp := session.MakeRequest(t, req, http.StatusOK) + var apiNL []api.NotificationThread + DecodeJSON(t, resp, &apiNL) + + assert.Len(t, apiNL, 1) + assert.EqualValues(t, 5, apiNL[0].ID) + + // test filter + before := "2000-01-01T01%3A06%3A59%2B00%3A00" //946688819 + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?all=%s&before=%s&token=%s", "true", before, token)) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + + assert.Len(t, apiNL, 3) + assert.EqualValues(t, 4, apiNL[0].ID) + assert.EqualValues(t, true, apiNL[0].Unread) + assert.EqualValues(t, false, apiNL[0].Pinned) + assert.EqualValues(t, 3, apiNL[1].ID) + assert.EqualValues(t, false, apiNL[1].Unread) + assert.EqualValues(t, true, apiNL[1].Pinned) + assert.EqualValues(t, 2, apiNL[2].ID) + assert.EqualValues(t, false, apiNL[2].Unread) + assert.EqualValues(t, false, apiNL[2].Pinned) + + // -- GET /repos/{owner}/{repo}/notifications -- + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?token=%s", user2.Name, repo1.Name, token)) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + + assert.Len(t, apiNL, 1) + assert.EqualValues(t, 4, apiNL[0].ID) + + // -- GET /notifications/threads/{id} -- + // get forbidden + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d?token=%s", 1, token)) + resp = session.MakeRequest(t, req, http.StatusForbidden) + + // get own + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d?token=%s", thread5.ID, token)) + resp = session.MakeRequest(t, req, http.StatusOK) + var apiN api.NotificationThread + DecodeJSON(t, resp, &apiN) + + assert.EqualValues(t, 5, apiN.ID) + assert.EqualValues(t, false, apiN.Pinned) + assert.EqualValues(t, true, apiN.Unread) + assert.EqualValues(t, "issue4", apiN.Subject.Title) + assert.EqualValues(t, "Issue", apiN.Subject.Type) + assert.EqualValues(t, thread5.Issue.APIURL(), apiN.Subject.URL) + assert.EqualValues(t, thread5.Repository.HTMLURL(), apiN.Repository.HTMLURL) + + // -- mark notifications as read -- + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?token=%s", token)) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + assert.Len(t, apiNL, 2) + + lastReadAt := "2000-01-01T00%3A50%3A01%2B00%3A00" //946687801 <- only Notification 4 is in this filter ... + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?last_read_at=%s&token=%s", user2.Name, repo1.Name, lastReadAt, token)) + resp = session.MakeRequest(t, req, http.StatusResetContent) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?token=%s", token)) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + assert.Len(t, apiNL, 1) + + // -- PATCH /notifications/threads/{id} -- + req = NewRequest(t, "PATCH", fmt.Sprintf("/api/v1/notifications/threads/%d?token=%s", thread5.ID, token)) + resp = session.MakeRequest(t, req, http.StatusResetContent) + + assert.Equal(t, models.NotificationStatusUnread, thread5.Status) + thread5 = models.AssertExistsAndLoadBean(t, &models.Notification{ID: 5}).(*models.Notification) + assert.Equal(t, models.NotificationStatusRead, thread5.Status) +} diff --git a/models/fixtures/notification.yml b/models/fixtures/notification.yml index fe5c47287..bd279d4bb 100644 --- a/models/fixtures/notification.yml +++ b/models/fixtures/notification.yml @@ -7,7 +7,7 @@ updated_by: 2 issue_id: 1 created_unix: 946684800 - updated_unix: 946684800 + updated_unix: 946684820 - id: 2 @@ -17,8 +17,8 @@ source: 1 # issue updated_by: 1 issue_id: 2 - created_unix: 946684800 - updated_unix: 946684800 + created_unix: 946685800 + updated_unix: 946685820 - id: 3 @@ -27,9 +27,9 @@ status: 3 # pinned source: 1 # issue updated_by: 1 - issue_id: 2 - created_unix: 946684800 - updated_unix: 946684800 + issue_id: 3 + created_unix: 946686800 + updated_unix: 946686800 - id: 4 @@ -38,6 +38,17 @@ status: 1 # unread source: 1 # issue updated_by: 1 - issue_id: 2 - created_unix: 946684800 - updated_unix: 946684800 \ No newline at end of file + issue_id: 5 + created_unix: 946687800 + updated_unix: 946687800 + +- + id: 5 + user_id: 2 + repo_id: 2 + status: 1 # unread + source: 1 # issue + updated_by: 5 + issue_id: 4 + created_unix: 946688800 + updated_unix: 946688820 diff --git a/models/issue.go b/models/issue.go index 3986aeee1..aeeb70d27 100644 --- a/models/issue.go +++ b/models/issue.go @@ -843,6 +843,20 @@ func (issue *Issue) GetLastEventLabel() string { return "repo.issues.opened_by" } +// GetLastComment return last comment for the current issue. +func (issue *Issue) GetLastComment() (*Comment, error) { + var c Comment + exist, err := x.Where("type = ?", CommentTypeComment). + And("issue_id = ?", issue.ID).Desc("id").Get(&c) + if err != nil { + return nil, err + } + if !exist { + return nil, nil + } + return &c, nil +} + // GetLastEventLabelFake returns the localization label for the current issue without providing a link in the username. func (issue *Issue) GetLastEventLabelFake() string { if issue.IsClosed { diff --git a/models/issue_comment.go b/models/issue_comment.go index 3ba679021..9caab1dc4 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -8,6 +8,7 @@ package models import ( "fmt" + "path" "strings" "code.gitea.io/gitea/modules/git" @@ -235,6 +236,22 @@ func (c *Comment) HTMLURL() string { return fmt.Sprintf("%s#%s", c.Issue.HTMLURL(), c.HashTag()) } +// APIURL formats a API-string to the issue-comment +func (c *Comment) APIURL() string { + err := c.LoadIssue() + if err != nil { // Silently dropping errors :unamused: + log.Error("LoadIssue(%d): %v", c.IssueID, err) + return "" + } + err = c.Issue.loadRepo(x) + if err != nil { // Silently dropping errors :unamused: + log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) + return "" + } + + return c.Issue.Repo.APIURL() + "/" + path.Join("issues/comments", fmt.Sprint(c.ID)) +} + // IssueURL formats a URL-string to the issue func (c *Comment) IssueURL() string { err := c.LoadIssue() diff --git a/models/notification.go b/models/notification.go index 5c03b4925..8e9bca0dc 100644 --- a/models/notification.go +++ b/models/notification.go @@ -6,8 +6,14 @@ package models import ( "fmt" + "path" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" + "xorm.io/xorm" ) type ( @@ -47,17 +53,67 @@ type Notification struct { IssueID int64 `xorm:"INDEX NOT NULL"` CommitID string `xorm:"INDEX"` CommentID int64 - Comment *Comment `xorm:"-"` UpdatedBy int64 `xorm:"INDEX NOT NULL"` Issue *Issue `xorm:"-"` Repository *Repository `xorm:"-"` + Comment *Comment `xorm:"-"` + User *User `xorm:"-"` CreatedUnix timeutil.TimeStamp `xorm:"created INDEX NOT NULL"` UpdatedUnix timeutil.TimeStamp `xorm:"updated INDEX NOT NULL"` } +// FindNotificationOptions represent the filters for notifications. If an ID is 0 it will be ignored. +type FindNotificationOptions struct { + UserID int64 + RepoID int64 + IssueID int64 + Status NotificationStatus + UpdatedAfterUnix int64 + UpdatedBeforeUnix int64 +} + +// ToCond will convert each condition into a xorm-Cond +func (opts *FindNotificationOptions) ToCond() builder.Cond { + cond := builder.NewCond() + if opts.UserID != 0 { + cond = cond.And(builder.Eq{"notification.user_id": opts.UserID}) + } + if opts.RepoID != 0 { + cond = cond.And(builder.Eq{"notification.repo_id": opts.RepoID}) + } + if opts.IssueID != 0 { + cond = cond.And(builder.Eq{"notification.issue_id": opts.IssueID}) + } + if opts.Status != 0 { + cond = cond.And(builder.Eq{"notification.status": opts.Status}) + } + if opts.UpdatedAfterUnix != 0 { + cond = cond.And(builder.Gte{"notification.updated_unix": opts.UpdatedAfterUnix}) + } + if opts.UpdatedBeforeUnix != 0 { + cond = cond.And(builder.Lte{"notification.updated_unix": opts.UpdatedBeforeUnix}) + } + return cond +} + +// ToSession will convert the given options to a xorm Session by using the conditions from ToCond and joining with issue table if required +func (opts *FindNotificationOptions) ToSession(e Engine) *xorm.Session { + return e.Where(opts.ToCond()) +} + +func getNotifications(e Engine, options FindNotificationOptions) (nl NotificationList, err error) { + err = options.ToSession(e).OrderBy("notification.updated_unix DESC").Find(&nl) + return +} + +// GetNotifications returns all notifications that fit to the given options. +func GetNotifications(opts FindNotificationOptions) (NotificationList, error) { + return getNotifications(x, opts) +} + // CreateOrUpdateIssueNotifications creates an issue notification // for each watcher, or updates it if already exists func CreateOrUpdateIssueNotifications(issueID, commentID int64, notificationAuthorID int64) error { @@ -238,22 +294,124 @@ func notificationsForUser(e Engine, user *User, statuses []NotificationStatus, p return } +// APIFormat converts a Notification to api.NotificationThread +func (n *Notification) APIFormat() *api.NotificationThread { + result := &api.NotificationThread{ + ID: n.ID, + Unread: !(n.Status == NotificationStatusRead || n.Status == NotificationStatusPinned), + Pinned: n.Status == NotificationStatusPinned, + UpdatedAt: n.UpdatedUnix.AsTime(), + URL: n.APIURL(), + } + + //since user only get notifications when he has access to use minimal access mode + if n.Repository != nil { + result.Repository = n.Repository.APIFormat(AccessModeRead) + } + + //handle Subject + switch n.Source { + case NotificationSourceIssue: + result.Subject = &api.NotificationSubject{Type: "Issue"} + if n.Issue != nil { + result.Subject.Title = n.Issue.Title + result.Subject.URL = n.Issue.APIURL() + comment, err := n.Issue.GetLastComment() + if err == nil && comment != nil { + result.Subject.LatestCommentURL = comment.APIURL() + } + } + case NotificationSourcePullRequest: + result.Subject = &api.NotificationSubject{Type: "Pull"} + if n.Issue != nil { + result.Subject.Title = n.Issue.Title + result.Subject.URL = n.Issue.APIURL() + comment, err := n.Issue.GetLastComment() + if err == nil && comment != nil { + result.Subject.LatestCommentURL = comment.APIURL() + } + } + case NotificationSourceCommit: + result.Subject = &api.NotificationSubject{ + Type: "Commit", + Title: n.CommitID, + } + //unused until now + } + + return result +} + +// LoadAttributes load Repo Issue User and Comment if not loaded +func (n *Notification) LoadAttributes() (err error) { + return n.loadAttributes(x) +} + +func (n *Notification) loadAttributes(e Engine) (err error) { + if err = n.loadRepo(e); err != nil { + return + } + if err = n.loadIssue(e); err != nil { + return + } + if err = n.loadUser(e); err != nil { + return + } + if err = n.loadComment(e); err != nil { + return + } + return +} + +func (n *Notification) loadRepo(e Engine) (err error) { + if n.Repository == nil { + n.Repository, err = getRepositoryByID(e, n.RepoID) + if err != nil { + return fmt.Errorf("getRepositoryByID [%d]: %v", n.RepoID, err) + } + } + return nil +} + +func (n *Notification) loadIssue(e Engine) (err error) { + if n.Issue == nil { + n.Issue, err = getIssueByID(e, n.IssueID) + if err != nil { + return fmt.Errorf("getIssueByID [%d]: %v", n.IssueID, err) + } + return n.Issue.loadAttributes(e) + } + return nil +} + +func (n *Notification) loadComment(e Engine) (err error) { + if n.Comment == nil && n.CommentID > 0 { + n.Comment, err = GetCommentByID(n.CommentID) + if err != nil { + return fmt.Errorf("GetCommentByID [%d]: %v", n.CommentID, err) + } + } + return nil +} + +func (n *Notification) loadUser(e Engine) (err error) { + if n.User == nil { + n.User, err = getUserByID(e, n.UserID) + if err != nil { + return fmt.Errorf("getUserByID [%d]: %v", n.UserID, err) + } + } + return nil +} + // GetRepo returns the repo of the notification func (n *Notification) GetRepo() (*Repository, error) { - n.Repository = new(Repository) - _, err := x. - Where("id = ?", n.RepoID). - Get(n.Repository) - return n.Repository, err + return n.Repository, n.loadRepo(x) } // GetIssue returns the issue of the notification func (n *Notification) GetIssue() (*Issue, error) { - n.Issue = new(Issue) - _, err := x. - Where("id = ?", n.IssueID). - Get(n.Issue) - return n.Issue, err + return n.Issue, n.loadIssue(x) } // HTMLURL formats a URL-string to the notification @@ -264,9 +422,34 @@ func (n *Notification) HTMLURL() string { return n.Issue.HTMLURL() } +// APIURL formats a URL-string to the notification +func (n *Notification) APIURL() string { + return setting.AppURL + path.Join("api/v1/notifications/threads", fmt.Sprintf("%d", n.ID)) +} + // NotificationList contains a list of notifications type NotificationList []*Notification +// APIFormat converts a NotificationList to api.NotificationThread list +func (nl NotificationList) APIFormat() []*api.NotificationThread { + var result = make([]*api.NotificationThread, 0, len(nl)) + for _, n := range nl { + result = append(result, n.APIFormat()) + } + return result +} + +// LoadAttributes load Repo Issue User and Comment if not loaded +func (nl NotificationList) LoadAttributes() (err error) { + for i := 0; i < len(nl); i++ { + err = nl[i].LoadAttributes() + if err != nil { + return + } + } + return +} + func (nl NotificationList) getPendingRepoIDs() []int64 { var ids = make(map[int64]struct{}, len(nl)) for _, notification := range nl { @@ -486,7 +669,7 @@ func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { // SetNotificationStatus change the notification status func SetNotificationStatus(notificationID int64, user *User, status NotificationStatus) error { - notification, err := getNotificationByID(notificationID) + notification, err := getNotificationByID(x, notificationID) if err != nil { return err } @@ -501,9 +684,14 @@ func SetNotificationStatus(notificationID int64, user *User, status Notification return err } -func getNotificationByID(notificationID int64) (*Notification, error) { +// GetNotificationByID return notification by ID +func GetNotificationByID(notificationID int64) (*Notification, error) { + return getNotificationByID(x, notificationID) +} + +func getNotificationByID(e Engine, notificationID int64) (*Notification, error) { notification := new(Notification) - ok, err := x. + ok, err := e. Where("id = ?", notificationID). Get(notification) @@ -512,7 +700,7 @@ func getNotificationByID(notificationID int64) (*Notification, error) { } if !ok { - return nil, fmt.Errorf("Notification %d does not exists", notificationID) + return nil, ErrNotExist{ID: notificationID} } return notification, nil diff --git a/models/notification_test.go b/models/notification_test.go index 728be7182..6485f8dc7 100644 --- a/models/notification_test.go +++ b/models/notification_test.go @@ -31,11 +31,13 @@ func TestNotificationsForUser(t *testing.T) { statuses := []NotificationStatus{NotificationStatusRead, NotificationStatusUnread} notfs, err := NotificationsForUser(user, statuses, 1, 10) assert.NoError(t, err) - if assert.Len(t, notfs, 2) { - assert.EqualValues(t, 2, notfs[0].ID) + if assert.Len(t, notfs, 3) { + assert.EqualValues(t, 5, notfs[0].ID) assert.EqualValues(t, user.ID, notfs[0].UserID) assert.EqualValues(t, 4, notfs[1].ID) assert.EqualValues(t, user.ID, notfs[1].UserID) + assert.EqualValues(t, 2, notfs[2].ID) + assert.EqualValues(t, user.ID, notfs[2].UserID) } } diff --git a/modules/structs/notifications.go b/modules/structs/notifications.go new file mode 100644 index 000000000..b1e8b7781 --- /dev/null +++ b/modules/structs/notifications.go @@ -0,0 +1,28 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package structs + +import ( + "time" +) + +// NotificationThread expose Notification on API +type NotificationThread struct { + ID int64 `json:"id"` + Repository *Repository `json:"repository"` + Subject *NotificationSubject `json:"subject"` + Unread bool `json:"unread"` + Pinned bool `json:"pinned"` + UpdatedAt time.Time `json:"updated_at"` + URL string `json:"url"` +} + +// NotificationSubject contains the notification subject (Issue/Pull/Commit) +type NotificationSubject struct { + Title string `json:"title"` + URL string `json:"url"` + LatestCommentURL string `json:"latest_comment_url"` + Type string `json:"type" binding:"In(Issue,Pull,Commit)"` +} diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 7387037d3..ebc651516 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -56,10 +56,10 @@ func CreateUser(ctx *context.APIContext, form api.CreateUserOption) { // responses: // "201": // "$ref": "#/responses/User" - // "403": - // "$ref": "#/responses/forbidden" // "400": // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" // "422": // "$ref": "#/responses/validationError" diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9f1895189..ccce00e2b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -70,6 +70,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/admin" "code.gitea.io/gitea/routers/api/v1/misc" + "code.gitea.io/gitea/routers/api/v1/notify" "code.gitea.io/gitea/routers/api/v1/org" "code.gitea.io/gitea/routers/api/v1/repo" _ "code.gitea.io/gitea/routers/api/v1/swagger" // for swagger generation @@ -512,6 +513,16 @@ func RegisterRoutes(m *macaron.Macaron) { m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown) m.Post("/markdown/raw", misc.MarkdownRaw) + // Notifications + m.Group("/notifications", func() { + m.Combo(""). + Get(notify.ListNotifications). + Put(notify.ReadNotifications) + m.Combo("/threads/:id"). + Get(notify.GetThread). + Patch(notify.ReadThread) + }, reqToken()) + // Users m.Group("/users", func() { m.Get("/search", user.Search) @@ -610,6 +621,9 @@ func RegisterRoutes(m *macaron.Macaron) { m.Combo("").Get(reqAnyRepoReader(), repo.Get). Delete(reqToken(), reqOwner(), repo.Delete). Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), context.RepoRef(), repo.Edit) + m.Combo("/notifications"). + Get(reqToken(), notify.ListRepoNotifications). + Put(reqToken(), notify.ReadRepoNotifications) m.Group("/hooks", func() { m.Combo("").Get(repo.ListHooks). Post(bind(api.CreateHookOption{}), repo.CreateHook) diff --git a/routers/api/v1/notify/repo.go b/routers/api/v1/notify/repo.go new file mode 100644 index 000000000..b939d90f0 --- /dev/null +++ b/routers/api/v1/notify/repo.go @@ -0,0 +1,151 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package notify + +import ( + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/routers/api/v1/utils" +) + +// ListRepoNotifications list users's notification threads on a specific repo +func ListRepoNotifications(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/notifications notification notifyGetRepoList + // --- + // summary: List users's notification threads on a specific repo + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: all + // in: query + // description: If true, show notifications marked as read. Default value is false + // type: string + // required: false + // - name: since + // in: query + // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // required: false + // - name: before + // in: query + // description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // required: false + // responses: + // "200": + // "$ref": "#/responses/NotificationThreadList" + + before, since, err := utils.GetQueryBeforeSince(ctx) + if err != nil { + ctx.InternalServerError(err) + return + } + opts := models.FindNotificationOptions{ + UserID: ctx.User.ID, + RepoID: ctx.Repo.Repository.ID, + UpdatedBeforeUnix: before, + UpdatedAfterUnix: since, + } + qAll := strings.Trim(ctx.Query("all"), " ") + if qAll != "true" { + opts.Status = models.NotificationStatusUnread + } + nl, err := models.GetNotifications(opts) + if err != nil { + ctx.InternalServerError(err) + return + } + err = nl.LoadAttributes() + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, nl.APIFormat()) +} + +// ReadRepoNotifications mark notification threads as read on a specific repo +func ReadRepoNotifications(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/notifications notification notifyReadRepoList + // --- + // summary: Mark notification threads as read on a specific repo + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: last_read_at + // in: query + // description: Describes the last point that notifications were checked. Anything updated since this time will not be updated. + // type: string + // format: date-time + // required: false + // responses: + // "205": + // "$ref": "#/responses/empty" + + lastRead := int64(0) + qLastRead := strings.Trim(ctx.Query("last_read_at"), " ") + if len(qLastRead) > 0 { + tmpLastRead, err := time.Parse(time.RFC3339, qLastRead) + if err != nil { + ctx.InternalServerError(err) + return + } + if !tmpLastRead.IsZero() { + lastRead = tmpLastRead.Unix() + } + } + opts := models.FindNotificationOptions{ + UserID: ctx.User.ID, + RepoID: ctx.Repo.Repository.ID, + UpdatedBeforeUnix: lastRead, + Status: models.NotificationStatusUnread, + } + nl, err := models.GetNotifications(opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + for _, n := range nl { + err := models.SetNotificationStatus(n.ID, ctx.User, models.NotificationStatusRead) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.Status(http.StatusResetContent) + } + + ctx.Status(http.StatusResetContent) +} diff --git a/routers/api/v1/notify/threads.go b/routers/api/v1/notify/threads.go new file mode 100644 index 000000000..d0119e993 --- /dev/null +++ b/routers/api/v1/notify/threads.go @@ -0,0 +1,101 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package notify + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" +) + +// GetThread get notification by ID +func GetThread(ctx *context.APIContext) { + // swagger:operation GET /notifications/threads/{id} notification notifyGetThread + // --- + // summary: Get notification thread by ID + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of notification thread + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/NotificationThread" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + n := getThread(ctx) + if n == nil { + return + } + if err := n.LoadAttributes(); err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, n.APIFormat()) +} + +// ReadThread mark notification as read by ID +func ReadThread(ctx *context.APIContext) { + // swagger:operation PATCH /notifications/threads/{id} notification notifyReadThread + // --- + // summary: Mark notification thread as read by ID + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of notification thread + // type: string + // required: true + // responses: + // "205": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + n := getThread(ctx) + if n == nil { + return + } + + err := models.SetNotificationStatus(n.ID, ctx.User, models.NotificationStatusRead) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.Status(http.StatusResetContent) +} + +func getThread(ctx *context.APIContext) *models.Notification { + n, err := models.GetNotificationByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrNotExist(err) { + ctx.Error(http.StatusNotFound, "GetNotificationByID", err) + } else { + ctx.InternalServerError(err) + } + return nil + } + if n.UserID != ctx.User.ID && !ctx.User.IsAdmin { + ctx.Error(http.StatusForbidden, "GetNotificationByID", fmt.Errorf("only user itself and admin are allowed to read/change this thread %d", n.ID)) + return nil + } + return n +} diff --git a/routers/api/v1/notify/user.go b/routers/api/v1/notify/user.go new file mode 100644 index 000000000..d16e4da0e --- /dev/null +++ b/routers/api/v1/notify/user.go @@ -0,0 +1,129 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package notify + +import ( + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/routers/api/v1/utils" +) + +// ListNotifications list users's notification threads +func ListNotifications(ctx *context.APIContext) { + // swagger:operation GET /notifications notification notifyGetList + // --- + // summary: List users's notification threads + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: all + // in: query + // description: If true, show notifications marked as read. Default value is false + // type: string + // required: false + // - name: since + // in: query + // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // required: false + // - name: before + // in: query + // description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // required: false + // responses: + // "200": + // "$ref": "#/responses/NotificationThreadList" + + before, since, err := utils.GetQueryBeforeSince(ctx) + if err != nil { + ctx.InternalServerError(err) + return + } + opts := models.FindNotificationOptions{ + UserID: ctx.User.ID, + UpdatedBeforeUnix: before, + UpdatedAfterUnix: since, + } + qAll := strings.Trim(ctx.Query("all"), " ") + if qAll != "true" { + opts.Status = models.NotificationStatusUnread + } + nl, err := models.GetNotifications(opts) + if err != nil { + ctx.InternalServerError(err) + return + } + err = nl.LoadAttributes() + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, nl.APIFormat()) +} + +// ReadNotifications mark notification threads as read +func ReadNotifications(ctx *context.APIContext) { + // swagger:operation PUT /notifications notification notifyReadList + // --- + // summary: Mark notification threads as read + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: last_read_at + // in: query + // description: Describes the last point that notifications were checked. Anything updated since this time will not be updated. + // type: string + // format: date-time + // required: false + // responses: + // "205": + // "$ref": "#/responses/empty" + + lastRead := int64(0) + qLastRead := strings.Trim(ctx.Query("last_read_at"), " ") + if len(qLastRead) > 0 { + tmpLastRead, err := time.Parse(time.RFC3339, qLastRead) + if err != nil { + ctx.InternalServerError(err) + return + } + if !tmpLastRead.IsZero() { + lastRead = tmpLastRead.Unix() + } + } + opts := models.FindNotificationOptions{ + UserID: ctx.User.ID, + UpdatedBeforeUnix: lastRead, + Status: models.NotificationStatusUnread, + } + nl, err := models.GetNotifications(opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + for _, n := range nl { + err := models.SetNotificationStatus(n.ID, ctx.User, models.NotificationStatusRead) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.Status(http.StatusResetContent) + } + + ctx.Status(http.StatusResetContent) +} diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index d0551320f..85ef41978 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -136,6 +136,8 @@ func GetPullRequest(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/PullRequest" + // "404": + // "$ref": "#/responses/notFound" pr, err := models.GetPullRequestByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { diff --git a/routers/api/v1/swagger/notify.go b/routers/api/v1/swagger/notify.go new file mode 100644 index 000000000..7d45da0e1 --- /dev/null +++ b/routers/api/v1/swagger/notify.go @@ -0,0 +1,23 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// NotificationThread +// swagger:response NotificationThread +type swaggerNotificationThread struct { + // in:body + Body api.NotificationThread `json:"body"` +} + +// NotificationThreadList +// swagger:response NotificationThreadList +type swaggerNotificationThreadList struct { + // in:body + Body []api.NotificationThread `json:"body"` +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 4e37f65d1..79f760b7a 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -425,6 +425,143 @@ } } }, + "/notifications": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notification" + ], + "summary": "List users's notification threads", + "operationId": "notifyGetList", + "parameters": [ + { + "type": "string", + "description": "If true, show notifications marked as read. Default value is false", + "name": "all", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Only show notifications updated after the given time. This is a timestamp in RFC 3339 format", + "name": "since", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Only show notifications updated before the given time. This is a timestamp in RFC 3339 format", + "name": "before", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/NotificationThreadList" + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notification" + ], + "summary": "Mark notification threads as read", + "operationId": "notifyReadList", + "parameters": [ + { + "type": "string", + "format": "date-time", + "description": "Describes the last point that notifications were checked. Anything updated since this time will not be updated.", + "name": "last_read_at", + "in": "query" + } + ], + "responses": { + "205": { + "$ref": "#/responses/empty" + } + } + } + }, + "/notifications/threads/{id}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notification" + ], + "summary": "Get notification thread by ID", + "operationId": "notifyGetThread", + "parameters": [ + { + "type": "string", + "description": "id of notification thread", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/NotificationThread" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notification" + ], + "summary": "Mark notification thread as read by ID", + "operationId": "notifyReadThread", + "parameters": [ + { + "type": "string", + "description": "id of notification thread", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "205": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/org/{org}/repos": { "post": { "consumes": [ @@ -5231,6 +5368,103 @@ } } }, + "/repos/{owner}/{repo}/notifications": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notification" + ], + "summary": "List users's notification threads on a specific repo", + "operationId": "notifyGetRepoList", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "If true, show notifications marked as read. Default value is false", + "name": "all", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Only show notifications updated after the given time. This is a timestamp in RFC 3339 format", + "name": "since", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "Only show notifications updated before the given time. This is a timestamp in RFC 3339 format", + "name": "before", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/NotificationThreadList" + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notification" + ], + "summary": "Mark notification threads as read on a specific repo", + "operationId": "notifyReadRepoList", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "Describes the last point that notifications were checked. Anything updated since this time will not be updated.", + "name": "last_read_at", + "in": "query" + } + ], + "responses": { + "205": { + "$ref": "#/responses/empty" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -5397,6 +5631,9 @@ "responses": { "200": { "$ref": "#/responses/PullRequest" + }, + "404": { + "$ref": "#/responses/notFound" } } }, @@ -10584,6 +10821,64 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "NotificationSubject": { + "description": "NotificationSubject contains the notification subject (Issue/Pull/Commit)", + "type": "object", + "properties": { + "latest_comment_url": { + "type": "string", + "x-go-name": "LatestCommentURL" + }, + "title": { + "type": "string", + "x-go-name": "Title" + }, + "type": { + "type": "string", + "x-go-name": "Type" + }, + "url": { + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "NotificationThread": { + "description": "NotificationThread expose Notification on API", + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "pinned": { + "type": "boolean", + "x-go-name": "Pinned" + }, + "repository": { + "$ref": "#/definitions/Repository" + }, + "subject": { + "$ref": "#/definitions/NotificationSubject" + }, + "unread": { + "type": "boolean", + "x-go-name": "Unread" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "UpdatedAt" + }, + "url": { + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Organization": { "description": "Organization represents an organization", "type": "object", @@ -12012,6 +12307,21 @@ } } }, + "NotificationThread": { + "description": "NotificationThread", + "schema": { + "$ref": "#/definitions/NotificationThread" + } + }, + "NotificationThreadList": { + "description": "NotificationThreadList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/NotificationThread" + } + } + }, "Organization": { "description": "Organization", "schema": {